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:
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user