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:
@@ -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 => (
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
40422
ui-design.pen
40422
ui-design.pen
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user