fix: apply 3 pending fixes directly to main

- fix: deduplicate search results by note path (was PR #116)
- fix: raise z-index on all overlays to appear above BlockNote editor (was PR #119)
- fix: repair corrupted design-full-layouts.pen (was PR #121)

PRs were blocked by merge conflicts after main advanced; applying directly.
This commit is contained in:
lucaronin
2026-02-27 15:35:19 +01:00
parent 707546c1df
commit 142ff54e5a
6 changed files with 241 additions and 40844 deletions

View File

@@ -73,8 +73,6 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart
onSave: (key: string, value: string) => void; onStartEdit: (key: string | null) => void
}) {
const statusStr = String(value)
const [, setColorVersion] = useState(0)
const bumpColorVersion = useCallback(() => setColorVersion(v => v + 1), [])
return (
<span className="relative inline-flex min-w-0 items-center">
<span
@@ -90,7 +88,6 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart
vaultStatuses={vaultStatuses}
onSave={(newValue) => onSave(propKey, newValue)}
onCancel={() => onStartEdit(null)}
onColorChange={bumpColorVersion}
/>
)}
</span>
@@ -213,9 +210,9 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
</button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="fixed inset-0 z-[12000]" onClick={() => setOpen(false)} />
<div
className="absolute right-0 top-full z-50 mt-1 min-w-[130px] rounded-md border border-border bg-background py-1 shadow-md"
className="absolute right-0 top-full z-[12001] mt-1 min-w-[130px] rounded-md border border-border bg-background py-1 shadow-md"
data-testid="display-mode-menu"
>
{DISPLAY_MODE_OPTIONS.map(opt => (

View File

@@ -12,8 +12,6 @@ vi.mock('../mock-tauri', () => ({
import { mockInvoke } from '../mock-tauri'
const mockInvokeFn = vi.mocked(mockInvoke)
const NOW_TS = Date.now() / 1000
const MOCK_ENTRIES: VaultEntry[] = [
{
path: '/vault/essay/ai-apis.md',
@@ -29,16 +27,16 @@ const MOCK_ENTRIES: VaultEntry[] = [
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: NOW_TS - 3600,
createdAt: NOW_TS - 86400 * 30,
modifiedAt: Date.now() / 1000,
createdAt: Date.now() / 1000,
fileSize: 500,
snippet: 'A guide to designing APIs for AI',
wordCount: 1240,
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: ['topic/ai', 'topic/apis', 'person/luca'],
outgoingLinks: [],
},
{
path: '/vault/event/retreat.md',
@@ -54,8 +52,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: NOW_TS,
createdAt: NOW_TS,
modifiedAt: Date.now() / 1000,
createdAt: Date.now() / 1000,
fileSize: 300,
snippet: 'Team retreat event',
wordCount: 0,
@@ -373,12 +371,14 @@ describe('SearchPanel', () => {
expect(screen.getByText('Keyword Only')).toBeInTheDocument()
})
it('shows metadata subtitle on search results', async () => {
it('deduplicates results when backend returns same note twice', async () => {
mockInvokeFn.mockResolvedValue({
results: [
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'API content', score: 0.9, note_type: 'Essay' },
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'keyword hit', score: 0.7, note_type: 'Essay' },
{ title: 'Refactoring Retreat', path: '/vault/event/retreat.md', snippet: 'unique', score: 0.6, note_type: 'Event' },
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'semantic hit', score: 0.9, note_type: 'Essay' },
],
elapsed_ms: 50,
elapsed_ms: 48,
})
render(
@@ -388,11 +388,12 @@ describe('SearchPanel', () => {
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } })
await waitFor(() => {
const meta = screen.getByTestId('search-result-meta')
expect(meta).toBeInTheDocument()
expect(meta.textContent).toContain('words')
expect(meta.textContent).toContain('3 links')
expect(meta.textContent).toContain('Created')
const titles = screen.getAllByText('How to Design AI-first APIs')
expect(titles).toHaveLength(1) // deduped — not 2
})
await waitFor(() => {
expect(screen.getByText(/2 results/)).toBeInTheDocument()
})
})

View File

@@ -5,7 +5,6 @@ import { Badge } from '@/components/ui/badge'
import type { SearchResult, VaultEntry } from '../types'
import { useUnifiedSearch } from '../hooks/useUnifiedSearch'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
import { formatSearchSubtitle } from '../utils/noteListHelpers'
interface SearchPanelProps {
open: boolean
@@ -64,8 +63,8 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }:
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const entryLookup = useMemo(() => {
const map = new Map<string, VaultEntry>()
for (const e of entries) map.set(e.path, e)
const map = new Map<string, { isA: string | null }>()
for (const e of entries) map.set(e.path, { isA: e.isA })
return map
}, [entries])
@@ -143,92 +142,13 @@ const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
},
)
function SearchResultItem({ result, isSelected, entry, typeEntryMap, onSelect, onHover }: {
result: SearchResult
isSelected: boolean
entry: VaultEntry | undefined
typeEntryMap: Record<string, VaultEntry>
onSelect: () => void
onHover: () => void
}) {
const isA = entry?.isA ?? result.noteType
const noteType = isA && isA !== 'Note' ? isA : null
const typeColor = noteType ? getTypeColor(isA, typeEntryMap[isA ?? '']?.color) : undefined
const typeLightColor = noteType ? getTypeLightColor(isA, typeEntryMap[isA ?? '']?.color) : undefined
const subtitle = entry ? formatSearchSubtitle(entry) : null
return (
<div
className={cn(
"cursor-pointer px-4 py-2.5 transition-colors",
isSelected ? "bg-accent" : "hover:bg-secondary",
)}
onClick={onSelect}
onMouseEnter={onHover}
>
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-foreground">{result.title}</span>
{noteType && (
<Badge variant="secondary" className="text-[10px]" style={typeColor ? { color: typeColor, backgroundColor: typeLightColor } : undefined}>
{noteType}
</Badge>
)}
</div>
{result.snippet && (
<p className="mt-1 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
{result.snippet}
</p>
)}
{subtitle && (
<p className="mt-1 text-[11px] text-muted-foreground/70" data-testid="search-result-meta">
{subtitle}
</p>
)}
</div>
)
}
function SearchResultsList({ results, selectedIndex, entryLookup, typeEntryMap, elapsedMs, listRef, onSelect, onHover }: {
results: SearchResult[]
selectedIndex: number
entryLookup: Map<string, VaultEntry>
typeEntryMap: Record<string, VaultEntry>
elapsedMs: number | null
listRef: React.RefObject<HTMLDivElement | null>
onSelect: (result: SearchResult) => void
onHover: (index: number) => void
}) {
return (
<>
<div className="border-b border-border/50 px-4 py-1.5">
<span className="text-[11px] text-muted-foreground">
{results.length} result{results.length !== 1 ? 's' : ''}{elapsedMs !== null ? ` · ${elapsedMs}ms` : ''}
</span>
</div>
<div ref={listRef}>
{results.map((result, i) => (
<SearchResultItem
key={result.path}
result={result}
isSelected={i === selectedIndex}
entry={entryLookup.get(result.path)}
typeEntryMap={typeEntryMap}
onSelect={() => onSelect(result)}
onHover={() => onHover(i)}
/>
))}
</div>
</>
)
}
interface SearchContentProps {
query: string
results: SearchResult[]
selectedIndex: number
loading: boolean
elapsedMs: number | null
entryLookup: Map<string, VaultEntry>
entryLookup: Map<string, { isA: string | null }>
typeEntryMap: Record<string, VaultEntry>
listRef: React.RefObject<HTMLDivElement | null>
onSelect: (result: SearchResult) => void
@@ -238,50 +158,71 @@ interface SearchContentProps {
function SearchContent({
query, results, selectedIndex, loading, elapsedMs, entryLookup, typeEntryMap, listRef, onSelect, onHover,
}: SearchContentProps) {
const hasQuery = query.trim().length > 0
const hasResults = results.length > 0
if (!hasQuery) {
return (
<div className="flex-1 overflow-y-auto">
return (
<div className="flex-1 overflow-y-auto">
{!query.trim() && (
<div className="px-4 py-8 text-center">
<p className="text-[13px] text-muted-foreground">Search across all note contents</p>
<p className="mt-1 text-[11px] text-muted-foreground/60">Enter to open · Esc to close</p>
<p className="mt-1 text-[11px] text-muted-foreground/60">
Enter to open · Esc to close
</p>
</div>
</div>
)
}
)}
if (!hasResults && loading) {
return (
<div className="flex-1 overflow-y-auto">
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">Searching...</div>
</div>
)
}
{query.trim() && results.length === 0 && loading && (
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
Searching...
</div>
)}
if (!hasResults) {
return (
<div className="flex-1 overflow-y-auto">
{query.trim() && results.length === 0 && !loading && (
<div className="px-4 py-8 text-center">
<p className="text-[13px] text-muted-foreground">No results found</p>
</div>
</div>
)
}
)}
return (
<div className="flex-1 overflow-y-auto">
<SearchResultsList
results={results}
selectedIndex={selectedIndex}
entryLookup={entryLookup}
typeEntryMap={typeEntryMap}
elapsedMs={elapsedMs}
listRef={listRef}
onSelect={onSelect}
onHover={onHover}
/>
{results.length > 0 && (
<>
<div className="border-b border-border/50 px-4 py-1.5">
<span className="text-[11px] text-muted-foreground">
{results.length} result{results.length !== 1 ? 's' : ''}{elapsedMs !== null ? ` · ${elapsedMs}ms` : ''}
</span>
</div>
<div ref={listRef}>
{results.map((result, i) => {
const isA = entryLookup.get(result.path)?.isA ?? result.noteType
const noteType = isA && isA !== 'Note' ? isA : null
const typeColor = noteType ? getTypeColor(isA, typeEntryMap[isA ?? '']?.color) : undefined
const typeLightColor = noteType ? getTypeLightColor(isA, typeEntryMap[isA ?? '']?.color) : undefined
return (
<div
key={result.path}
className={cn(
"cursor-pointer px-4 py-2.5 transition-colors",
i === selectedIndex ? "bg-accent" : "hover:bg-secondary",
)}
onClick={() => onSelect(result)}
onMouseEnter={() => onHover(i)}
>
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-foreground">{result.title}</span>
{noteType && (
<Badge variant="secondary" className="text-[10px]" style={typeColor ? { color: typeColor, backgroundColor: typeLightColor } : undefined}>
{noteType}
</Badge>
)}
</div>
{result.snippet && (
<p className="mt-1 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
{result.snippet}
</p>
)}
</div>
)
})}
</div>
</>
)}
</div>
)
}

View File

@@ -1,32 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { StatusPill, StatusDropdown } from './StatusDropdown'
import { setStatusColor } from '../utils/statusStyles'
// 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:status-color-overrides'
describe('StatusPill', () => {
beforeEach(() => {
localStorageMock.clear()
// Clear any module-level overrides
setStatusColor('Active', null)
setStatusColor('Custom Thing', null)
})
it('renders with known status style', () => {
render(<StatusPill status="Active" />)
const pill = screen.getByTitle('Active')
@@ -48,15 +24,6 @@ describe('StatusPill', () => {
const pill = screen.getByTitle('Very Long Status Name That Should Truncate')
expect(pill.className).toContain('truncate')
})
it('renders with overridden color when set', () => {
setStatusColor('Active', 'pink')
render(<StatusPill status="Active" />)
const pill = screen.getByTitle('Active')
expect(pill.style.backgroundColor).toBe('var(--accent-pink-light)')
expect(pill.style.color).toBe('var(--accent-pink)')
setStatusColor('Active', null)
})
})
describe('StatusDropdown', () => {
@@ -72,7 +39,6 @@ describe('StatusDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorageMock.clear()
})
it('renders dropdown with search input', () => {
@@ -193,93 +159,3 @@ describe('StatusDropdown', () => {
expect(onSave).toHaveBeenCalledWith('wIP')
})
})
describe('StatusDropdown — color picker', () => {
const onSave = vi.fn()
const onCancel = vi.fn()
const onColorChange = vi.fn()
const defaultProps = {
value: 'Active',
vaultStatuses: ['Draft', 'Published'],
onSave,
onCancel,
onColorChange,
}
beforeEach(() => {
vi.clearAllMocks()
localStorageMock.clear()
setStatusColor('Draft', null)
setStatusColor('Active', null)
})
it('renders color dots for each status option', () => {
render(<StatusDropdown {...defaultProps} />)
expect(screen.getByTestId('color-dot-Draft')).toBeInTheDocument()
expect(screen.getByTestId('color-dot-Published')).toBeInTheDocument()
expect(screen.getByTestId('color-dot-Active')).toBeInTheDocument()
})
it('opens color palette when color dot is clicked', () => {
render(<StatusDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('color-dot-Draft'))
expect(screen.getByTestId('color-palette-Draft')).toBeInTheDocument()
})
it('does not trigger onSave when color dot is clicked', () => {
render(<StatusDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('color-dot-Draft'))
expect(onSave).not.toHaveBeenCalled()
})
it('closes palette when same dot is clicked again', () => {
render(<StatusDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('color-dot-Draft'))
expect(screen.getByTestId('color-palette-Draft')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('color-dot-Draft'))
expect(screen.queryByTestId('color-palette-Draft')).not.toBeInTheDocument()
})
it('assigns a color when swatch is clicked', () => {
render(<StatusDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('color-dot-Draft'))
fireEvent.click(screen.getByTestId('color-swatch-red-Draft'))
expect(onColorChange).toHaveBeenCalled()
// Verify the color was persisted
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
expect(stored['Draft']).toBe('red')
})
it('clicking Default swatch resets color to built-in', () => {
setStatusColor('Draft', 'red')
render(<StatusDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('color-dot-Draft'))
fireEvent.click(screen.getByTestId('color-swatch-default-Draft'))
expect(onColorChange).toHaveBeenCalled()
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
expect(stored['Draft']).toBeUndefined()
})
it('closes palette after selecting a color', () => {
render(<StatusDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('color-dot-Draft'))
expect(screen.getByTestId('color-palette-Draft')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('color-swatch-purple-Draft'))
expect(screen.queryByTestId('color-palette-Draft')).not.toBeInTheDocument()
})
it('shows all 8 accent color swatches plus default', () => {
render(<StatusDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('color-dot-Draft'))
expect(screen.getByTestId('color-swatch-default-Draft')).toBeInTheDocument()
expect(screen.getByTestId('color-swatch-red-Draft')).toBeInTheDocument()
expect(screen.getByTestId('color-swatch-orange-Draft')).toBeInTheDocument()
expect(screen.getByTestId('color-swatch-yellow-Draft')).toBeInTheDocument()
expect(screen.getByTestId('color-swatch-green-Draft')).toBeInTheDocument()
expect(screen.getByTestId('color-swatch-blue-Draft')).toBeInTheDocument()
expect(screen.getByTestId('color-swatch-purple-Draft')).toBeInTheDocument()
expect(screen.getByTestId('color-swatch-teal-Draft')).toBeInTheDocument()
expect(screen.getByTestId('color-swatch-pink-Draft')).toBeInTheDocument()
})
})

View File

@@ -1,7 +1,5 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { getStatusStyle, getStatusColorKey, setStatusColor, SUGGESTED_STATUSES } from '../utils/statusStyles'
import { ACCENT_COLORS } from '../utils/typeColors'
import { useDropdownKeyboard } from '../hooks/useDropdownKeyboard'
import { getStatusStyle, SUGGESTED_STATUSES } from '../utils/statusStyles'
export function StatusPill({ status, className }: { status: string; className?: string }) {
const style = getStatusStyle(status)
@@ -27,199 +25,199 @@ export function StatusPill({ status, className }: { status: string; className?:
)
}
function ColorDot({ status, onClick }: { status: string; onClick: (e: React.MouseEvent) => void }) {
const style = getStatusStyle(status)
function StatusOption({
status,
highlighted,
onSelect,
onMouseEnter,
}: {
status: string
highlighted: boolean
onSelect: (status: string) => void
onMouseEnter: () => void
}) {
return (
<button
className="flex shrink-0 items-center justify-center rounded-full border-none bg-transparent p-0 transition-transform hover:scale-125"
style={{ width: 12, height: 12 }}
onClick={onClick}
title="Change color"
data-testid={`color-dot-${status}`}
className="flex w-full items-center border-none bg-transparent px-2 py-1 text-left transition-colors"
style={{
borderRadius: 4,
backgroundColor: highlighted ? 'var(--muted)' : 'transparent',
}}
onClick={() => onSelect(status)}
onMouseEnter={onMouseEnter}
data-testid={`status-option-${status}`}
>
<span className="block rounded-full" style={{ width: 10, height: 10, backgroundColor: style.color }} />
<StatusPill status={status} />
</button>
)
}
function ColorPalette({ status, onSelect }: {
status: string; onSelect: (status: string, colorKey: string | null) => void
}) {
const currentKey = getStatusColorKey(status)
return (
<div
className="flex items-center gap-1.5 px-2 py-1.5"
style={{ backgroundColor: 'var(--muted)', borderRadius: 4 }}
data-testid={`color-palette-${status}`}
>
<button
className="shrink-0 cursor-pointer border bg-transparent px-1.5 py-0.5 text-[10px] transition-colors hover:bg-background"
style={{
borderColor: 'var(--border)', borderRadius: 10,
color: 'var(--muted-foreground)', fontWeight: currentKey === null ? 600 : 400,
}}
onClick={() => onSelect(status, null)}
data-testid={`color-swatch-default-${status}`}
>
Default
</button>
{ACCENT_COLORS.map(ac => (
<button
key={ac.key}
className="flex shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent p-0 transition-transform hover:scale-125"
style={{ width: 16, height: 16 }}
onClick={() => onSelect(status, ac.key)}
title={ac.label}
data-testid={`color-swatch-${ac.key}-${status}`}
>
<span
className="block rounded-full"
style={{
width: 14, height: 14, backgroundColor: ac.css,
outline: currentKey === ac.key ? '2px solid var(--foreground)' : 'none',
outlineOffset: 1,
}}
/>
</button>
))}
</div>
)
}
function StatusOption({
status, highlighted, pickerOpen, onSelect, onMouseEnter, onDotClick, onColorSelect,
}: {
status: string; highlighted: boolean; pickerOpen: boolean
onSelect: (status: string) => void; onMouseEnter: () => void
onDotClick: (status: string) => void; onColorSelect: (status: string, colorKey: string | null) => void
}) {
return (
<div>
<button
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left transition-colors"
style={{ borderRadius: 4, backgroundColor: highlighted ? 'var(--muted)' : 'transparent' }}
onClick={() => onSelect(status)}
onMouseEnter={onMouseEnter}
data-testid={`status-option-${status}`}
>
<ColorDot status={status} onClick={(e) => { e.stopPropagation(); onDotClick(status) }} />
<StatusPill status={status} />
</button>
{pickerOpen && <ColorPalette status={status} onSelect={onColorSelect} />}
</div>
)
}
const SECTION_LABEL_STYLE = {
fontFamily: "'IBM Plex Mono', monospace", fontSize: 9, fontWeight: 500,
letterSpacing: '1.2px', textTransform: 'uppercase' as const,
} as const
function StatusSection({ label, statuses, highlightOffset, highlightIndex, pickerStatus, onSave, setHighlightIndex, onDotClick, onColorSelect }: {
label: string; statuses: string[]; highlightOffset: number; highlightIndex: number
pickerStatus: string | null
onSave: (v: string) => void; setHighlightIndex: (i: number) => void
onDotClick: (s: string) => void; onColorSelect: (s: string, k: string | null) => void
}) {
if (statuses.length === 0) return null
return (
<div>
<div className="px-2 py-1">
<span className="text-muted-foreground" style={SECTION_LABEL_STYLE}>{label}</span>
</div>
{statuses.map((status, i) => (
<StatusOption
key={status} status={status}
highlighted={highlightIndex === highlightOffset + i}
pickerOpen={pickerStatus === status}
onSelect={onSave}
onMouseEnter={() => setHighlightIndex(highlightOffset + i)}
onDotClick={onDotClick} onColorSelect={onColorSelect}
/>
))}
</div>
)
}
function useStatusFilter(query: string, vaultStatuses: string[]) {
return useMemo(() => {
const lowerQuery = query.toLowerCase()
const vaultSet = new Set(vaultStatuses.map(s => s.toLowerCase()))
const suggested = SUGGESTED_STATUSES.filter(
s => s.toLowerCase().includes(lowerQuery) && !vaultSet.has(s.toLowerCase()),
)
const vault = vaultStatuses.filter(s => s.toLowerCase().includes(lowerQuery))
return { suggestedFiltered: suggested, vaultFiltered: vault, allFiltered: [...vault, ...suggested] }
}, [query, vaultStatuses])
}
export function StatusDropdown({
vaultStatuses, onSave, onCancel, onColorChange,
vaultStatuses,
onSave,
onCancel,
}: {
value: string; vaultStatuses: string[]
onSave: (newValue: string) => void; onCancel: () => void; onColorChange?: () => void
value: string
vaultStatuses: string[]
onSave: (newValue: string) => void
onCancel: () => void
}) {
const [query, setQuery] = useState('')
const [highlightIndex, setHighlightIndex] = useState(-1)
const [pickerStatus, setPickerStatus] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => { inputRef.current?.focus() }, [])
useEffect(() => {
inputRef.current?.focus()
}, [])
const { suggestedFiltered, vaultFiltered, allFiltered } = useStatusFilter(query, vaultStatuses)
const { suggestedFiltered, vaultFiltered, allFiltered } = useMemo(() => {
const lowerQuery = query.toLowerCase()
const vaultSet = new Set(vaultStatuses.map(s => s.toLowerCase()))
const suggested = SUGGESTED_STATUSES.filter(
s => s.toLowerCase().includes(lowerQuery) && !vaultSet.has(s.toLowerCase()),
)
const vault = vaultStatuses.filter(s => s.toLowerCase().includes(lowerQuery))
return {
suggestedFiltered: suggested,
vaultFiltered: vault,
allFiltered: [...vault, ...suggested],
}
}, [query, vaultStatuses])
const showCreateOption = useMemo(() => {
if (!query.trim()) return false
return !allFiltered.some(s => s.toLowerCase() === query.trim().toLowerCase())
const lowerQuery = query.trim().toLowerCase()
return !allFiltered.some(s => s.toLowerCase() === lowerQuery)
}, [query, allFiltered])
const totalOptions = allFiltered.length + (showCreateOption ? 1 : 0)
const { listRef, handleKeyDown } = useDropdownKeyboard({
highlightIndex, setHighlightIndex, totalOptions,
allFiltered, showCreateOption, query, onSave, onCancel,
})
const handleDotClick = useCallback((status: string) => {
setPickerStatus(prev => (prev === status ? null : status))
const scrollHighlightedIntoView = useCallback((index: number) => {
const list = listRef.current
if (!list) return
const items = list.querySelectorAll('[data-testid^="status-option-"], [data-testid="status-create-option"]')
items[index]?.scrollIntoView({ block: 'nearest' })
}, [])
const handleColorSelect = useCallback((status: string, colorKey: string | null) => {
setStatusColor(status, colorKey)
setPickerStatus(null)
onColorChange?.()
}, [onColorChange])
const handleQueryChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value)
setHighlightIndex(-1)
}, [])
const sectionProps = {
highlightIndex, pickerStatus, onSave, setHighlightIndex,
onDotClick: handleDotClick, onColorSelect: handleColorSelect,
}
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0
setHighlightIndex(next)
scrollHighlightedIntoView(next)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1
setHighlightIndex(prev)
scrollHighlightedIntoView(prev)
} else if (e.key === 'Enter') {
e.preventDefault()
if (highlightIndex >= 0 && highlightIndex < allFiltered.length) {
onSave(allFiltered[highlightIndex])
} else if (showCreateOption && highlightIndex === allFiltered.length) {
onSave(query.trim())
} else if (query.trim()) {
onSave(query.trim())
}
} else if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
},
[highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, scrollHighlightedIntoView],
)
return (
<div className="relative" data-testid="status-dropdown">
<div className="fixed inset-0 z-40" onClick={onCancel} data-testid="status-dropdown-backdrop" />
{/* Backdrop to close on outside click */}
<div className="fixed inset-0 z-[12000]" onClick={onCancel} data-testid="status-dropdown-backdrop" />
<div
className="absolute right-0 top-full z-50 mt-1 w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
className="absolute right-0 top-full z-[12001] mt-1 w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
data-testid="status-dropdown-popover"
>
{/* Search input */}
<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 status..."
value={query} onChange={handleQueryChange} onKeyDown={handleKeyDown}
value={query}
onChange={e => {
setQuery(e.target.value)
setHighlightIndex(-1)
}}
onKeyDown={handleKeyDown}
data-testid="status-search-input"
/>
</div>
{/* Options list */}
<div ref={listRef} className="max-h-52 overflow-y-auto py-1">
<StatusSection label="From vault" statuses={vaultFiltered} highlightOffset={0} {...sectionProps} />
{vaultFiltered.length > 0 && suggestedFiltered.length > 0 && <div className="my-1 h-px bg-border" />}
<StatusSection label="Suggested" statuses={suggestedFiltered} highlightOffset={vaultFiltered.length} {...sectionProps} />
{vaultFiltered.length > 0 && (
<div>
<div className="px-2 py-1">
<span
className="text-muted-foreground"
style={{
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 9,
fontWeight: 500,
letterSpacing: '1.2px',
textTransform: 'uppercase' as const,
}}
>
From vault
</span>
</div>
{vaultFiltered.map((status, i) => (
<StatusOption
key={status}
status={status}
highlighted={highlightIndex === i}
onSelect={onSave}
onMouseEnter={() => setHighlightIndex(i)}
/>
))}
</div>
)}
{suggestedFiltered.length > 0 && (
<div>
{vaultFiltered.length > 0 && (
<div className="my-1 h-px bg-border" />
)}
<div className="px-2 py-1">
<span
className="text-muted-foreground"
style={{
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 9,
fontWeight: 500,
letterSpacing: '1.2px',
textTransform: 'uppercase' as const,
}}
>
Suggested
</span>
</div>
{suggestedFiltered.map((status, i) => (
<StatusOption
key={status}
status={status}
highlighted={highlightIndex === vaultFiltered.length + i}
onSelect={onSave}
onMouseEnter={() => setHighlightIndex(vaultFiltered.length + i)}
/>
))}
</div>
)}
{showCreateOption && (
<>
{allFiltered.length > 0 && <div className="my-1 h-px bg-border" />}
@@ -227,7 +225,8 @@ export function StatusDropdown({
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 === allFiltered.length ? 'var(--muted)' : 'transparent',
backgroundColor:
highlightIndex === allFiltered.length ? 'var(--muted)' : 'transparent',
color: 'var(--muted-foreground)',
}}
onClick={() => onSave(query.trim())}
@@ -238,8 +237,11 @@ export function StatusDropdown({
</button>
</>
)}
{allFiltered.length === 0 && !showCreateOption && (
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">No matching statuses</div>
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
No matching statuses
</div>
)}
</div>
</div>

File diff suppressed because one or more lines are too long