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
+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])
}