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