f96c6a99d1
- 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>
397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|
import {
|
|
ClipboardList,
|
|
FileText,
|
|
Pill,
|
|
AlertTriangle,
|
|
FlaskConical,
|
|
FolderOpen,
|
|
Send,
|
|
Search,
|
|
X,
|
|
} from 'lucide-react'
|
|
import type { ViewId } from '../types/pmr'
|
|
import { useAccessibility } from '../contexts/AccessibilityContext'
|
|
import { buildSearchIndex, groupResultsBySection, type SearchResult } from '../lib/search'
|
|
import type { FuseResult } from 'fuse.js'
|
|
|
|
interface NavItem {
|
|
id: ViewId
|
|
label: string
|
|
icon: React.ReactNode
|
|
}
|
|
|
|
interface ClinicalSidebarProps {
|
|
activeView: ViewId
|
|
onViewChange: (view: ViewId) => void
|
|
isTablet?: boolean
|
|
}
|
|
|
|
const navItems: NavItem[] = [
|
|
{ id: 'summary', label: 'Summary', icon: <ClipboardList size={18} /> },
|
|
{ id: 'consultations', label: 'Experience', icon: <FileText size={18} /> },
|
|
{ id: 'medications', label: 'Skills', icon: <Pill size={18} /> },
|
|
{ id: 'problems', label: 'Achievements', icon: <AlertTriangle size={18} /> },
|
|
{ id: 'investigations', label: 'Projects', icon: <FlaskConical size={18} /> },
|
|
{ id: 'documents', label: 'Education', icon: <FolderOpen size={18} /> },
|
|
{ id: 'referrals', label: 'Contact', icon: <Send size={18} /> },
|
|
]
|
|
|
|
function getCurrentTime(): string {
|
|
const now = new Date()
|
|
return now.toLocaleTimeString('en-GB', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }: ClinicalSidebarProps) {
|
|
const [currentTime, setCurrentTime] = useState(getCurrentTime)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [isSearchFocused, setIsSearchFocused] = useState(false)
|
|
const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
|
|
const [hoveredItem, setHoveredItem] = useState<ViewId | null>(null)
|
|
const navButtonRefs = useRef<(HTMLButtonElement | null)[]>([])
|
|
const { focusAfterLoginRef, setExpandedItem } = useAccessibility()
|
|
|
|
// Build search index once on mount
|
|
const searchIndex = useMemo(() => buildSearchIndex(), [])
|
|
|
|
const handleNavClick = useCallback(
|
|
(view: ViewId) => {
|
|
onViewChange(view)
|
|
window.location.hash = view
|
|
},
|
|
[onViewChange]
|
|
)
|
|
|
|
const handleNavKeyDown = useCallback((e: React.KeyboardEvent, index: number) => {
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault()
|
|
if (index < navItems.length - 1) {
|
|
setFocusedIndex(index + 1)
|
|
navButtonRefs.current[index + 1]?.focus()
|
|
}
|
|
break
|
|
case 'ArrowUp':
|
|
e.preventDefault()
|
|
if (index > 0) {
|
|
setFocusedIndex(index - 1)
|
|
navButtonRefs.current[index - 1]?.focus()
|
|
}
|
|
break
|
|
case 'Enter':
|
|
case ' ':
|
|
e.preventDefault()
|
|
handleNavClick(navItems[index].id)
|
|
break
|
|
case 'Home':
|
|
e.preventDefault()
|
|
setFocusedIndex(0)
|
|
navButtonRefs.current[0]?.focus()
|
|
break
|
|
case 'End':
|
|
e.preventDefault()
|
|
setFocusedIndex(navItems.length - 1)
|
|
navButtonRefs.current[navItems.length - 1]?.focus()
|
|
break
|
|
}
|
|
}, [handleNavClick])
|
|
|
|
// Update clock every minute
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
setCurrentTime(getCurrentTime())
|
|
}, 60000)
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
// Hash routing
|
|
useEffect(() => {
|
|
const handleHashChange = () => {
|
|
const hash = window.location.hash.slice(1) as ViewId
|
|
if (navItems.some(item => item.id === hash)) {
|
|
onViewChange(hash)
|
|
}
|
|
}
|
|
|
|
handleHashChange()
|
|
window.addEventListener('hashchange', handleHashChange)
|
|
return () => window.removeEventListener('hashchange', handleHashChange)
|
|
}, [onViewChange])
|
|
|
|
// Alt+1-7 keyboard shortcuts and "/" for search
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.altKey && e.key >= '1' && e.key <= '7') {
|
|
e.preventDefault()
|
|
const index = parseInt(e.key) - 1
|
|
if (navItems[index]) {
|
|
const view = navItems[index].id
|
|
onViewChange(view)
|
|
window.location.hash = view
|
|
}
|
|
}
|
|
if (e.key === '/' && !isSearchFocused && document.activeElement?.tagName !== 'INPUT') {
|
|
e.preventDefault()
|
|
const searchInput = document.getElementById('sidebar-search')
|
|
searchInput?.focus()
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [onViewChange, isSearchFocused])
|
|
|
|
// Set focus-after-login ref to first nav button
|
|
useEffect(() => {
|
|
if (navButtonRefs.current[0]) {
|
|
;(focusAfterLoginRef as React.MutableRefObject<HTMLButtonElement | null>).current = navButtonRefs.current[0]
|
|
}
|
|
}, [focusAfterLoginRef])
|
|
|
|
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Escape') {
|
|
setSearchQuery('')
|
|
;(e.target as HTMLInputElement).blur()
|
|
}
|
|
}
|
|
|
|
const clearSearch = () => {
|
|
setSearchQuery('')
|
|
const searchInput = document.getElementById('sidebar-search')
|
|
searchInput?.focus()
|
|
}
|
|
|
|
// Fuzzy search with fuse.js
|
|
const searchResults = useMemo(() => {
|
|
if (!searchQuery.trim() || searchQuery.length < 2) return []
|
|
const results = searchIndex.search(searchQuery)
|
|
return results.slice(0, 10) // Limit to top 10 results
|
|
}, [searchQuery, searchIndex])
|
|
|
|
// 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 ──
|
|
if (isTablet) {
|
|
return (
|
|
<aside
|
|
role="navigation"
|
|
aria-label="Clinical record navigation"
|
|
className="hidden md:flex lg:hidden flex-col w-14 h-full bg-pmr-sidebar border-r border-[#334155] text-white"
|
|
>
|
|
{/* Header */}
|
|
<div className="p-2 border-b border-white/10">
|
|
<div className="font-ui font-medium text-[10px] text-white/50 text-center leading-tight">
|
|
PMR
|
|
</div>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<nav className="flex-1 py-2 overflow-y-auto">
|
|
<ul role="menu" aria-label="Record sections">
|
|
{navItems.map((item, index) => (
|
|
<li key={item.id} role="none" className="relative">
|
|
{index === 1 && (
|
|
<div className="mx-2 my-1 border-t border-white/10" role="separator" aria-hidden="true" />
|
|
)}
|
|
<button
|
|
ref={el => { navButtonRefs.current[index] = el }}
|
|
type="button"
|
|
role="menuitem"
|
|
tabIndex={focusedIndex === null ? (index === 0 ? 0 : -1) : (focusedIndex === index ? 0 : -1)}
|
|
aria-current={activeView === item.id ? 'page' : undefined}
|
|
aria-label={item.label}
|
|
onClick={() => handleNavClick(item.id)}
|
|
onKeyDown={e => handleNavKeyDown(e, index)}
|
|
onMouseEnter={() => setHoveredItem(item.id)}
|
|
onMouseLeave={() => setHoveredItem(null)}
|
|
className={`
|
|
w-full flex items-center justify-center h-11
|
|
transition-colors duration-150 relative
|
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:ring-inset
|
|
${activeView === item.id
|
|
? 'text-white bg-white/[0.12] border-l-[3px] border-pmr-nhsblue'
|
|
: 'text-white/70 hover:text-white hover:bg-white/[0.08] border-l-[3px] border-transparent'}
|
|
`}
|
|
>
|
|
<span className={activeView === item.id ? 'text-white' : ''}>
|
|
{item.icon}
|
|
</span>
|
|
{/* Tooltip on hover */}
|
|
{hoveredItem === item.id && (
|
|
<div className="absolute left-full ml-2 px-2.5 py-1.5 bg-gray-900 text-white text-xs rounded whitespace-nowrap z-50 font-ui shadow-lg pointer-events-none">
|
|
{item.label}
|
|
</div>
|
|
)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</nav>
|
|
|
|
{/* Footer */}
|
|
<div className="p-2 border-t border-white/10">
|
|
<div className="font-ui text-[9px] text-[#64748B] text-center leading-relaxed">
|
|
<div>A.C</div>
|
|
<div>{currentTime}</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
// ── Desktop: 220px full sidebar ──
|
|
return (
|
|
<aside
|
|
role="navigation"
|
|
aria-label="Clinical record navigation"
|
|
className="hidden lg:flex flex-col w-[220px] h-full bg-pmr-sidebar border-r border-[#334155] text-white"
|
|
>
|
|
{/* Header branding */}
|
|
<div className="p-4 border-b border-white/10">
|
|
<div className="font-ui font-medium text-[13px] text-white/50 leading-tight">
|
|
CareerRecord PMR
|
|
</div>
|
|
<div className="font-ui text-[11px] text-white/40 mt-0.5">v1.0.0</div>
|
|
</div>
|
|
|
|
{/* Search input */}
|
|
<div className="p-3 border-b border-white/10">
|
|
<div className="relative">
|
|
<Search
|
|
size={14}
|
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
|
|
/>
|
|
<input
|
|
id="sidebar-search"
|
|
type="text"
|
|
placeholder="Search record..."
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
onFocus={() => setIsSearchFocused(true)}
|
|
onBlur={() => setIsSearchFocused(false)}
|
|
onKeyDown={handleSearchKeyDown}
|
|
className="w-full h-9 pl-8 pr-7 bg-white/[0.05] border border-white/10 rounded text-sm font-ui text-white placeholder-white/40 focus:outline-none focus:border-pmr-nhsblue focus:bg-white/[0.10] transition-colors"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
type="button"
|
|
onClick={clearSearch}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/70 transition-colors"
|
|
aria-label="Clear search"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
{/* Search results dropdown — grouped by section */}
|
|
{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 max-h-[400px] overflow-y-auto shadow-lg">
|
|
{Array.from(groupedResults.entries()).map(([sectionLabel, results]) => {
|
|
// Find section icon
|
|
const navItem = navItems.find(item => item.label === sectionLabel)
|
|
return (
|
|
<div key={sectionLabel}>
|
|
{/* Section header */}
|
|
<div className="px-3 py-1.5 bg-white/[0.05] border-b border-white/10">
|
|
<div className="flex items-center gap-2">
|
|
{navItem && <span className="text-white/40">{navItem.icon}</span>}
|
|
<span className="font-ui text-xs font-semibold uppercase tracking-wide text-white/50">
|
|
{sectionLabel}
|
|
</span>
|
|
<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>
|
|
|
|
{/* Navigation items */}
|
|
<nav className="flex-1 py-2 overflow-y-auto">
|
|
<ul role="menu" aria-label="Record sections">
|
|
{navItems.map((item, index) => (
|
|
<li key={item.id} role="none">
|
|
{index === 1 && (
|
|
<div className="mx-3 my-1 border-t border-white/10" role="separator" aria-hidden="true" />
|
|
)}
|
|
<button
|
|
ref={el => { navButtonRefs.current[index] = el }}
|
|
type="button"
|
|
role="menuitem"
|
|
tabIndex={focusedIndex === null ? (index === 0 ? 0 : -1) : (focusedIndex === index ? 0 : -1)}
|
|
aria-current={activeView === item.id ? 'page' : undefined}
|
|
onClick={() => handleNavClick(item.id)}
|
|
onKeyDown={e => handleNavKeyDown(e, index)}
|
|
className={`
|
|
w-full flex items-center gap-3 h-[44px] px-4
|
|
font-ui text-[14px]
|
|
transition-colors duration-150
|
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:ring-inset
|
|
${activeView === item.id
|
|
? 'text-white bg-white/[0.12] border-l-[3px] border-pmr-nhsblue font-semibold'
|
|
: 'text-white/70 hover:text-white hover:bg-white/[0.08] border-l-[3px] border-transparent font-medium'}
|
|
`}
|
|
>
|
|
<span className={`w-[18px] h-[18px] flex items-center justify-center ${activeView === item.id ? 'text-white' : 'text-white/60'}`}>
|
|
{item.icon}
|
|
</span>
|
|
<span>{item.label}</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</nav>
|
|
|
|
{/* Footer: session info */}
|
|
<div className="p-4 border-t border-white/10">
|
|
<div className="font-ui text-[11px] text-[#64748B] leading-relaxed">
|
|
<div>Session: A.CHARLWOOD</div>
|
|
<div>Logged in: {currentTime}</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|