Task 13: Implement fuzzy search with fuse.js
- Installed fuse.js for fuzzy search functionality - Created src/lib/search.ts with buildSearchIndex and groupResultsBySection functions - Search index includes all consultations, medications, problems, investigations, and documents - Updated ClinicalSidebar to use fuse.js instead of simple filter - Search results grouped by section (Experience, Skills, Achievements, Projects, Education) - Section headers show icon and count - Each result shows title and highlight text (truncated) - Clicking a result navigates to the section and expands the matching item - Minimum 2 characters required for search - Top 10 results displayed - Clean dropdown styling with hover states - Integrates with AccessibilityContext to set expandedItem Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+10
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
@@ -2668,6 +2669,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fuse.js": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gensync": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { ViewId } from '../types/pmr'
|
import type { ViewId } from '../types/pmr'
|
||||||
import { useAccessibility } from '../contexts/AccessibilityContext'
|
import { useAccessibility } from '../contexts/AccessibilityContext'
|
||||||
|
import { buildSearchIndex, groupResultsBySection, type SearchResult } from '../lib/search'
|
||||||
|
import type { FuseResult } from 'fuse.js'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: ViewId
|
id: ViewId
|
||||||
@@ -50,7 +52,10 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
|
|||||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
|
const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
|
||||||
const [hoveredItem, setHoveredItem] = useState<ViewId | null>(null)
|
const [hoveredItem, setHoveredItem] = useState<ViewId | null>(null)
|
||||||
const navButtonRefs = useRef<(HTMLButtonElement | null)[]>([])
|
const navButtonRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||||
const { focusAfterLoginRef } = useAccessibility()
|
const { focusAfterLoginRef, setExpandedItem } = useAccessibility()
|
||||||
|
|
||||||
|
// Build search index once on mount
|
||||||
|
const searchIndex = useMemo(() => buildSearchIndex(), [])
|
||||||
|
|
||||||
const handleNavClick = useCallback(
|
const handleNavClick = useCallback(
|
||||||
(view: ViewId) => {
|
(view: ViewId) => {
|
||||||
@@ -159,13 +164,33 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
|
|||||||
searchInput?.focus()
|
searchInput?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
// Fuzzy search with fuse.js
|
||||||
if (!searchQuery.trim()) return []
|
const searchResults = useMemo(() => {
|
||||||
const query = searchQuery.toLowerCase()
|
if (!searchQuery.trim() || searchQuery.length < 2) return []
|
||||||
return navItems.filter(item =>
|
const results = searchIndex.search(searchQuery)
|
||||||
item.label.toLowerCase().includes(query)
|
return results.slice(0, 10) // Limit to top 10 results
|
||||||
)
|
}, [searchQuery, searchIndex])
|
||||||
}, [searchQuery])
|
|
||||||
|
// Group results by section for organized display
|
||||||
|
const groupedResults = useMemo(() => {
|
||||||
|
if (searchResults.length === 0) return new Map()
|
||||||
|
return groupResultsBySection(searchResults)
|
||||||
|
}, [searchResults])
|
||||||
|
|
||||||
|
const handleSearchResultClick = useCallback(
|
||||||
|
(result: FuseResult<SearchResult>) => {
|
||||||
|
// Navigate to the section
|
||||||
|
onViewChange(result.item.section)
|
||||||
|
window.location.hash = result.item.section
|
||||||
|
|
||||||
|
// Expand the matching item
|
||||||
|
setExpandedItem(result.item.id)
|
||||||
|
|
||||||
|
// Clear search
|
||||||
|
setSearchQuery('')
|
||||||
|
},
|
||||||
|
[onViewChange, setExpandedItem]
|
||||||
|
)
|
||||||
|
|
||||||
// ── Tablet: 56px icon-only sidebar ──
|
// ── Tablet: 56px icon-only sidebar ──
|
||||||
if (isTablet) {
|
if (isTablet) {
|
||||||
@@ -279,23 +304,45 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
|
|||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Search results dropdown */}
|
{/* Search results dropdown — grouped by section */}
|
||||||
{searchQuery && filteredItems.length > 0 && (
|
{searchQuery.trim().length >= 2 && groupedResults.size > 0 && (
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 bg-pmr-sidebar border border-white/10 rounded overflow-hidden z-50">
|
<div className="absolute top-full left-0 right-0 mt-1 bg-pmr-sidebar border border-white/10 rounded overflow-hidden z-50 max-h-[400px] overflow-y-auto shadow-lg">
|
||||||
{filteredItems.map(item => (
|
{Array.from(groupedResults.entries()).map(([sectionLabel, results]) => {
|
||||||
<button
|
// Find section icon
|
||||||
key={item.id}
|
const navItem = navItems.find(item => item.label === sectionLabel)
|
||||||
type="button"
|
return (
|
||||||
onClick={() => {
|
<div key={sectionLabel}>
|
||||||
handleNavClick(item.id)
|
{/* Section header */}
|
||||||
setSearchQuery('')
|
<div className="px-3 py-1.5 bg-white/[0.05] border-b border-white/10">
|
||||||
}}
|
<div className="flex items-center gap-2">
|
||||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-white/[0.10] transition-colors"
|
{navItem && <span className="text-white/40">{navItem.icon}</span>}
|
||||||
>
|
<span className="font-ui text-xs font-semibold uppercase tracking-wide text-white/50">
|
||||||
<span className="text-white/60">{item.icon}</span>
|
{sectionLabel}
|
||||||
<span className="font-ui text-sm">{item.label}</span>
|
</span>
|
||||||
</button>
|
<span className="font-ui text-xs text-white/30">
|
||||||
))}
|
({results.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Results for this section */}
|
||||||
|
{results.map((result: FuseResult<SearchResult>) => (
|
||||||
|
<button
|
||||||
|
key={result.item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSearchResultClick(result)}
|
||||||
|
className="w-full px-3 py-2.5 text-left hover:bg-white/[0.10] transition-colors border-b border-white/5 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="font-ui text-sm text-white leading-snug">
|
||||||
|
{result.item.title}
|
||||||
|
</div>
|
||||||
|
<div className="font-ui text-xs text-white/50 mt-0.5 line-clamp-1">
|
||||||
|
{result.item.highlight}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import Fuse, { type FuseResult } from 'fuse.js'
|
||||||
|
import type { ViewId } from '@/types/pmr'
|
||||||
|
|
||||||
|
// Import all data sources
|
||||||
|
import { consultations } from '@/data/consultations'
|
||||||
|
import { medications } from '@/data/medications'
|
||||||
|
import { problems } from '@/data/problems'
|
||||||
|
import { investigations } from '@/data/investigations'
|
||||||
|
import { documents } from '@/data/documents'
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
section: ViewId
|
||||||
|
sectionLabel: string
|
||||||
|
highlight: string
|
||||||
|
score?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a unified search index from all PMR content
|
||||||
|
export function buildSearchIndex(): Fuse<SearchResult> {
|
||||||
|
const searchableItems: SearchResult[] = []
|
||||||
|
|
||||||
|
// Index consultations (Experience)
|
||||||
|
consultations.forEach(consultation => {
|
||||||
|
searchableItems.push({
|
||||||
|
id: consultation.id,
|
||||||
|
title: consultation.role,
|
||||||
|
section: 'consultations',
|
||||||
|
sectionLabel: 'Experience',
|
||||||
|
highlight: `${consultation.role} at ${consultation.organization} — ${consultation.history}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Index medications (Skills)
|
||||||
|
medications.forEach(medication => {
|
||||||
|
searchableItems.push({
|
||||||
|
id: medication.id,
|
||||||
|
title: medication.name,
|
||||||
|
section: 'medications',
|
||||||
|
sectionLabel: 'Skills',
|
||||||
|
highlight: `${medication.name} — ${medication.frequency} use since ${medication.startYear}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Index problems (Achievements)
|
||||||
|
problems.forEach(problem => {
|
||||||
|
searchableItems.push({
|
||||||
|
id: problem.id,
|
||||||
|
title: problem.description,
|
||||||
|
section: 'problems',
|
||||||
|
sectionLabel: 'Achievements',
|
||||||
|
highlight: `[${problem.code}] ${problem.description} — ${problem.narrative}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Index investigations (Projects)
|
||||||
|
investigations.forEach(investigation => {
|
||||||
|
searchableItems.push({
|
||||||
|
id: investigation.id,
|
||||||
|
title: investigation.name,
|
||||||
|
section: 'investigations',
|
||||||
|
sectionLabel: 'Projects',
|
||||||
|
highlight: `${investigation.name} — ${investigation.methodology}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Index documents (Education)
|
||||||
|
documents.forEach(document => {
|
||||||
|
searchableItems.push({
|
||||||
|
id: document.id,
|
||||||
|
title: document.title,
|
||||||
|
section: 'documents',
|
||||||
|
sectionLabel: 'Education',
|
||||||
|
highlight: `${document.title} from ${document.source} (${document.date})`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fuse.js configuration for fuzzy search
|
||||||
|
const fuseOptions = {
|
||||||
|
keys: [
|
||||||
|
{ name: 'title', weight: 2 }, // Primary match on title
|
||||||
|
{ name: 'highlight', weight: 1 }, // Secondary match on full text
|
||||||
|
],
|
||||||
|
threshold: 0.3, // 0 = exact match, 1 = match anything
|
||||||
|
includeScore: true,
|
||||||
|
minMatchCharLength: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Fuse(searchableItems, fuseOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group search results by section
|
||||||
|
export function groupResultsBySection(results: FuseResult<SearchResult>[]): Map<string, FuseResult<SearchResult>[]> {
|
||||||
|
const grouped = new Map<string, FuseResult<SearchResult>[]>()
|
||||||
|
|
||||||
|
results.forEach(result => {
|
||||||
|
const sectionLabel = result.item.sectionLabel
|
||||||
|
if (!grouped.has(sectionLabel)) {
|
||||||
|
grouped.set(sectionLabel, [])
|
||||||
|
}
|
||||||
|
grouped.get(sectionLabel)!.push(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user