US-003: Create DetailPanelContext, DetailPanel component, and useFocusTrap hook

Implements core detail panel infrastructure for slide-in content panels:

- DetailPanelContext: Manages panel state (content, open/close, isOpen)
- DetailPanel: Slide-in panel component with backdrop, header, and scrollable body
- useFocusTrap: Keyboard focus trap hook for modal accessibility
- Width mapping: narrow (400px) for kpi/skill/education, wide (60vw) for consultation/project/career-role
- Title mapping derives from content data (kpi.label, skill.name, etc.)
- Close triggers: backdrop click, Escape key, X button
- ARIA: aria-modal, role=dialog, aria-labelledby
- Mobile responsive: both widths become 100vw on <768px
- prefers-reduced-motion: instant appear, no animations
- Placeholder content (real renderers in later stories)
- Export CardHeaderProps interface from Card.tsx
- Add responsive panel width CSS rules

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 23:02:59 +00:00
parent f7e9c88762
commit cf5399a767
5 changed files with 379 additions and 1 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ export function Card({ children, full, className, tileId }: CardProps) {
) )
} }
interface CardHeaderProps { export interface CardHeaderProps {
dotColor: 'teal' | 'amber' | 'green' | 'alert' | 'purple' dotColor: 'teal' | 'amber' | 'green' | 'alert' | 'purple'
title: string title: string
rightText?: string rightText?: string
+229
View File
@@ -0,0 +1,229 @@
import { useEffect, useRef } from 'react'
import { X } from 'lucide-react'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { useFocusTrap } from '@/hooks/useFocusTrap'
import { DetailPanelContent } from '@/types/pmr'
import type { CardHeaderProps } from './Card'
// Width mapping from content type
const widthMap: Record<DetailPanelContent['type'], 'narrow' | 'wide'> = {
kpi: 'narrow',
skill: 'narrow',
'skills-all': 'narrow',
consultation: 'wide',
project: 'wide',
education: 'narrow',
'career-role': 'wide',
}
// Title mapping from content data
function getPanelTitle(content: DetailPanelContent): string {
switch (content.type) {
case 'kpi':
return content.kpi.label
case 'skill':
return content.skill.name
case 'skills-all':
return 'All Medications'
case 'consultation':
return content.consultation.role
case 'project':
return content.investigation.name
case 'education':
return content.document.title
case 'career-role':
return content.consultation.role
}
}
// Dot color mapping from content type
function getDotColor(content: DetailPanelContent): CardHeaderProps['dotColor'] {
switch (content.type) {
case 'kpi':
return 'teal'
case 'skill':
case 'skills-all':
return 'amber'
case 'consultation':
case 'career-role':
return 'teal'
case 'project':
return 'amber'
case 'education':
return 'purple'
}
}
// Dot color value map (from Card.tsx)
const dotColorValueMap: Record<CardHeaderProps['dotColor'], string> = {
teal: '#0D6E6E',
amber: '#D97706',
green: '#059669',
alert: '#DC2626',
purple: '#7C3AED',
}
export function DetailPanel() {
const { content, closePanel, isOpen } = useDetailPanel()
const panelRef = useRef<HTMLDivElement>(null)
const titleId = 'detail-panel-title'
// Focus trap when open
useFocusTrap(panelRef, isOpen)
// Close on Escape key
useEffect(() => {
if (!isOpen) return
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closePanel()
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen, closePanel])
if (!isOpen || !content) return null
const width = widthMap[content.type]
const title = getPanelTitle(content)
const dotColor = getDotColor(content)
const dotColorValue = dotColorValueMap[dotColor]
return (
<>
{/* Backdrop */}
<div
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'var(--backdrop-bg)',
backdropFilter: 'blur(var(--backdrop-blur))',
zIndex: 1000,
animation: 'backdrop-fade-in 150ms ease-out',
}}
onClick={closePanel}
aria-hidden="true"
/>
{/* Panel */}
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="detail-panel"
data-width={width}
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
backgroundColor: 'var(--surface)',
boxShadow: 'var(--shadow-lg)',
zIndex: 1001,
display: 'flex',
flexDirection: 'column',
animation: 'panel-slide-in 250ms ease-out',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '20px 24px',
borderBottom: '1px solid var(--border-light)',
flexShrink: 0,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: dotColorValue,
flexShrink: 0,
}}
aria-hidden="true"
/>
<h2
id={titleId}
style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--text-primary)',
fontFamily: 'var(--font-ui)',
}}
>
{title}
</h2>
</div>
<button
onClick={closePanel}
aria-label="Close panel"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
border: 'none',
background: 'transparent',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
color: 'var(--text-secondary)',
transition: 'background-color 150ms, color 150ms',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent-light)'
e.currentTarget.style.color = 'var(--accent)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.color = 'var(--text-secondary)'
}}
>
<X size={20} />
</button>
</div>
{/* Body (scrollable) */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '24px',
}}
>
{/* Placeholder content - actual renderers will be added in later stories */}
<div
style={{
fontFamily: 'var(--font-ui)',
color: 'var(--text-secondary)',
fontSize: '14px',
}}
>
<p>
Detail panel for: <strong>{content.type}</strong>
</p>
<p style={{ marginTop: '8px', fontSize: '12px' }}>
Content renderers will be implemented in subsequent user stories.
</p>
</div>
</div>
</div>
</>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { createContext, useContext, useState, ReactNode } from 'react'
import { DetailPanelContent } from '@/types/pmr'
interface DetailPanelContextValue {
content: DetailPanelContent | null
openPanel: (content: DetailPanelContent) => void
closePanel: () => void
isOpen: boolean
}
const DetailPanelContext = createContext<DetailPanelContextValue | undefined>(
undefined
)
interface DetailPanelProviderProps {
children: ReactNode
}
export function DetailPanelProvider({ children }: DetailPanelProviderProps) {
const [content, setContent] = useState<DetailPanelContent | null>(null)
const openPanel = (newContent: DetailPanelContent) => {
setContent(newContent)
}
const closePanel = () => {
setContent(null)
}
const isOpen = content !== null
const value: DetailPanelContextValue = {
content,
openPanel,
closePanel,
isOpen,
}
return (
<DetailPanelContext.Provider value={value}>
{children}
</DetailPanelContext.Provider>
)
}
export function useDetailPanel(): DetailPanelContextValue {
const context = useContext(DetailPanelContext)
if (!context) {
throw new Error('useDetailPanel must be used within DetailPanelProvider')
}
return context
}
+80
View File
@@ -0,0 +1,80 @@
import { RefObject, useEffect } from 'react'
/**
* Focus trap hook for modal dialogs and panels
* Traps Tab/Shift+Tab within the container when active
* Returns focus to previously focused element when deactivated
*/
export function useFocusTrap(
containerRef: RefObject<HTMLElement>,
isActive: boolean
): void {
useEffect(() => {
if (!isActive || !containerRef.current) return
const container = containerRef.current
const previousActiveElement = document.activeElement as HTMLElement
// Get all focusable elements
const getFocusableElements = (): HTMLElement[] => {
const selectors = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
]
const elements = container.querySelectorAll<HTMLElement>(
selectors.join(', ')
)
return Array.from(elements).filter(
(el) => !el.hasAttribute('disabled') && el.offsetParent !== null
)
}
// Focus first element on mount
const focusableElements = getFocusableElements()
if (focusableElements.length > 0) {
focusableElements[0].focus()
}
// Handle Tab key to trap focus
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return
const focusable = getFocusableElements()
if (focusable.length === 0) return
const firstElement = focusable[0]
const lastElement = focusable[focusable.length - 1]
const activeElement = document.activeElement as HTMLElement
if (event.shiftKey) {
// Shift+Tab: moving backwards
if (activeElement === firstElement) {
event.preventDefault()
lastElement.focus()
}
} else {
// Tab: moving forwards
if (activeElement === lastElement) {
event.preventDefault()
firstElement.focus()
}
}
}
document.addEventListener('keydown', handleKeyDown)
// Cleanup: return focus to previous element
return () => {
document.removeEventListener('keydown', handleKeyDown)
if (previousActiveElement && previousActiveElement.focus) {
previousActiveElement.focus()
}
}
}, [isActive, containerRef])
}
+17
View File
@@ -423,6 +423,23 @@ textarea:focus-visible {
to { opacity: 1; } to { opacity: 1; }
} }
/* Detail panel responsive widths */
.detail-panel[data-width="narrow"] {
width: var(--panel-narrow);
}
.detail-panel[data-width="wide"] {
width: var(--panel-wide);
}
/* Mobile: both narrow and wide become full-width */
@media (max-width: 767px) {
.detail-panel[data-width="narrow"],
.detail-panel[data-width="wide"] {
width: 100vw;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
/* Disable pulse animation on status badge dot */ /* Disable pulse animation on status badge dot */
@keyframes pulse { @keyframes pulse {