feat: add global focus mode with cross-component dimming on hover
When hovering a constellation node, skill pill, or timeline item, non-related UI elements across all components dim to 0.25 opacity, creating a focused visual relationship view. The constellation axis and year labels also dim via CSS class. Respects reduced-motion.
This commit is contained in:
@@ -12,8 +12,9 @@ import { LastConsultationCard } from './LastConsultationCard'
|
||||
import { ChatWidget } from './ChatWidget'
|
||||
import { useActiveSection } from '@/hooks/useActiveSection'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import { timelineConsultations } from '@/data/timeline'
|
||||
import { timelineConsultations, timelineEntities } from '@/data/timeline'
|
||||
import { skills } from '@/data/skills'
|
||||
import { constellationNodes } from '@/data/constellation'
|
||||
import type { PaletteAction } from '@/lib/search'
|
||||
import { prefersReducedMotion, motionSafeTransition } from '@/lib/utils'
|
||||
|
||||
@@ -49,6 +50,47 @@ export function DashboardLayout() {
|
||||
[],
|
||||
)
|
||||
|
||||
// Global focus mode: tracks which entity (skill or role) is being hovered across all components
|
||||
const [globalFocusId, setGlobalFocusId] = useState<string | null>(null)
|
||||
|
||||
// Build lookup maps for resolving relationships between skills and roles
|
||||
const nodeTypeById = useMemo(
|
||||
() => new Map(constellationNodes.map(n => [n.id, n.type])),
|
||||
[],
|
||||
)
|
||||
const skillToRoles = useMemo(() => {
|
||||
const map = new Map<string, Set<string>>()
|
||||
for (const entity of timelineEntities) {
|
||||
for (const skillId of entity.skills) {
|
||||
if (!map.has(skillId)) map.set(skillId, new Set())
|
||||
map.get(skillId)!.add(entity.id)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [])
|
||||
const roleToSkills = useMemo(
|
||||
() => new Map(timelineEntities.map(e => [e.id, new Set(e.skills)])),
|
||||
[],
|
||||
)
|
||||
|
||||
// Derive the set of all IDs related to the focused entity
|
||||
const focusRelatedIds = useMemo(() => {
|
||||
if (!globalFocusId) return null
|
||||
const related = new Set<string>()
|
||||
related.add(globalFocusId)
|
||||
const nodeType = nodeTypeById.get(globalFocusId)
|
||||
if (nodeType === 'skill') {
|
||||
// Skill focused: related roles are those containing this skill
|
||||
const roles = skillToRoles.get(globalFocusId)
|
||||
if (roles) roles.forEach(r => related.add(r))
|
||||
} else {
|
||||
// Role/education focused: related skills are that entity's skills
|
||||
const entitySkills = roleToSkills.get(globalFocusId)
|
||||
if (entitySkills) entitySkills.forEach(s => related.add(s))
|
||||
}
|
||||
return related
|
||||
}, [globalFocusId, nodeTypeById, skillToRoles, roleToSkills])
|
||||
|
||||
// Signal constellation animation readiness when patient summary scrolls out of view
|
||||
useEffect(() => {
|
||||
const el = patientSummaryRef.current
|
||||
@@ -115,11 +157,14 @@ export function DashboardLayout() {
|
||||
|
||||
const handleNodeHighlight = useCallback((id: string | null) => {
|
||||
setHighlightedNodeId(id)
|
||||
setGlobalFocusId(id)
|
||||
}, [])
|
||||
|
||||
const handleNodeHover = useCallback((id: string | null) => {
|
||||
setHighlightedRoleId(id)
|
||||
}, [])
|
||||
const nodeType = id ? nodeTypeById.get(id) : null
|
||||
setHighlightedRoleId(nodeType !== 'skill' ? id : null)
|
||||
setGlobalFocusId(id)
|
||||
}, [nodeTypeById])
|
||||
|
||||
// Global Ctrl+K listener to open command palette
|
||||
useEffect(() => {
|
||||
@@ -243,11 +288,11 @@ export function DashboardLayout() {
|
||||
|
||||
|
||||
<div className="chronology-item">
|
||||
<LastConsultationCard highlightedRoleId={highlightedRoleId} />
|
||||
<LastConsultationCard highlightedRoleId={highlightedRoleId} focusRelatedIds={focusRelatedIds} />
|
||||
</div>
|
||||
|
||||
<div className="chronology-item">
|
||||
<TimelineInterventionsSubsection onNodeHighlight={handleNodeHighlight} highlightedRoleId={highlightedRoleId} />
|
||||
<TimelineInterventionsSubsection onNodeHighlight={handleNodeHighlight} highlightedRoleId={highlightedRoleId} focusRelatedIds={focusRelatedIds} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pathway-graph-sticky">
|
||||
@@ -258,6 +303,7 @@ export function DashboardLayout() {
|
||||
highlightedNodeId={highlightedNodeId}
|
||||
containerHeight={chronologyHeight}
|
||||
animationReady={constellationReady}
|
||||
globalFocusActive={globalFocusId !== null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -265,7 +311,7 @@ export function DashboardLayout() {
|
||||
</div>
|
||||
|
||||
<div data-tile-id="section-skills" style={{ marginTop: '22px' }}>
|
||||
<RepeatMedicationsSubsection onNodeHighlight={handleNodeHighlight} />
|
||||
<RepeatMedicationsSubsection onNodeHighlight={handleNodeHighlight} focusRelatedIds={focusRelatedIds} />
|
||||
</div>
|
||||
</ParentSection>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { hexToRgba, motionSafeTransition } from '@/lib/utils'
|
||||
interface ExpandableCardShellProps {
|
||||
isExpanded: boolean
|
||||
isHighlighted: boolean
|
||||
isDimmedByFocus?: boolean
|
||||
accentColor: string
|
||||
onToggle: () => void
|
||||
ariaLabel: string
|
||||
@@ -21,6 +22,7 @@ interface ExpandableCardShellProps {
|
||||
export function ExpandableCardShell({
|
||||
isExpanded,
|
||||
isHighlighted,
|
||||
isDimmedByFocus = false,
|
||||
accentColor,
|
||||
onToggle,
|
||||
ariaLabel,
|
||||
@@ -52,6 +54,10 @@ export function ExpandableCardShell({
|
||||
className={className}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={{
|
||||
opacity: isDimmedByFocus ? 0.25 : 1,
|
||||
transition: 'opacity 150ms ease-out',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -8,15 +8,17 @@ import { DEFAULT_ORG_COLOR } from '@/lib/theme-colors'
|
||||
|
||||
interface LastConsultationCardProps {
|
||||
highlightedRoleId?: string | null
|
||||
focusRelatedIds?: Set<string> | null
|
||||
}
|
||||
|
||||
export function LastConsultationCard({ highlightedRoleId }: LastConsultationCardProps) {
|
||||
export function LastConsultationCard({ highlightedRoleId, focusRelatedIds }: LastConsultationCardProps) {
|
||||
const { openPanel } = useDetailPanel()
|
||||
const consultation = timelineConsultations.find(c => c.isCurrent) ?? timelineConsultations[0]
|
||||
if (!consultation) {
|
||||
return null
|
||||
}
|
||||
const isHighlighted = highlightedRoleId === consultation.id
|
||||
const isDimmed = focusRelatedIds != null && !focusRelatedIds.has(consultation.id)
|
||||
|
||||
const handleOpenPanel = () => {
|
||||
openPanel({ type: 'consultation', consultation })
|
||||
@@ -67,9 +69,10 @@ export function LastConsultationCard({ highlightedRoleId }: LastConsultationCard
|
||||
border: '1px solid',
|
||||
borderColor: isHighlighted ? hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.2) : 'transparent',
|
||||
background: isHighlighted ? hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.03) : 'transparent',
|
||||
transition: 'border-color 150ms ease-out, background-color 150ms ease-out',
|
||||
transition: 'border-color 150ms ease-out, background-color 150ms ease-out, opacity 150ms ease-out',
|
||||
padding: '8px',
|
||||
margin: '-8px',
|
||||
opacity: isDimmed ? 0.25 : 1,
|
||||
}}
|
||||
>
|
||||
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Current role" />
|
||||
|
||||
@@ -26,9 +26,10 @@ interface SkillRowProps {
|
||||
yearsSuffix: string
|
||||
onClick: () => void
|
||||
onHighlight?: (id: string | null) => void
|
||||
isDimmedByFocus?: boolean
|
||||
}
|
||||
|
||||
function SkillRow({ skill, yearsSuffix, onClick, onHighlight }: SkillRowProps) {
|
||||
function SkillRow({ skill, yearsSuffix, onClick, onHighlight, isDimmedByFocus = false }: SkillRowProps) {
|
||||
const IconComponent = iconMap[skill.icon]
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -55,7 +56,8 @@ function SkillRow({ skill, yearsSuffix, onClick, onHighlight }: SkillRowProps) {
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border-light)',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||
transition: 'border-color 0.15s, box-shadow 0.15s, opacity 150ms ease-out',
|
||||
opacity: isDimmedByFocus ? 0.25 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||
@@ -134,6 +136,7 @@ interface CategorySectionProps {
|
||||
onSkillClick: (skill: SkillMedication) => void
|
||||
isFirst: boolean
|
||||
onNodeHighlight?: (id: string | null) => void
|
||||
focusRelatedIds?: Set<string> | null
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
@@ -144,6 +147,7 @@ function CategorySection({
|
||||
onSkillClick,
|
||||
isFirst,
|
||||
onNodeHighlight,
|
||||
focusRelatedIds,
|
||||
}: CategorySectionProps) {
|
||||
return (
|
||||
<div style={{ marginTop: isFirst ? 0 : '16px' }}>
|
||||
@@ -193,6 +197,7 @@ function CategorySection({
|
||||
yearsSuffix={yearsSuffix}
|
||||
onClick={() => onSkillClick(skill)}
|
||||
onHighlight={onNodeHighlight}
|
||||
isDimmedByFocus={focusRelatedIds != null && !focusRelatedIds.has(skill.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -202,9 +207,10 @@ function CategorySection({
|
||||
|
||||
interface RepeatMedicationsSubsectionProps {
|
||||
onNodeHighlight?: (id: string | null) => void
|
||||
focusRelatedIds?: Set<string> | null
|
||||
}
|
||||
|
||||
export function RepeatMedicationsSubsection({ onNodeHighlight }: RepeatMedicationsSubsectionProps) {
|
||||
export function RepeatMedicationsSubsection({ onNodeHighlight, focusRelatedIds }: RepeatMedicationsSubsectionProps) {
|
||||
const { openPanel } = useDetailPanel()
|
||||
const skillsCopy = getSkillsUICopy()
|
||||
|
||||
@@ -238,6 +244,7 @@ export function RepeatMedicationsSubsection({ onNodeHighlight }: RepeatMedicatio
|
||||
onSkillClick={handleSkillClick}
|
||||
isFirst
|
||||
onNodeHighlight={onNodeHighlight}
|
||||
focusRelatedIds={focusRelatedIds}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface TimelineInterventionItemProps {
|
||||
entity: TimelineEntity
|
||||
isExpanded: boolean
|
||||
isHighlightedFromGraph: boolean
|
||||
isDimmedByFocus: boolean
|
||||
isEducationAnchor: boolean
|
||||
onToggle: () => void
|
||||
onViewFull: () => void
|
||||
@@ -21,6 +22,7 @@ function TimelineInterventionItem({
|
||||
entity,
|
||||
isExpanded,
|
||||
isHighlightedFromGraph,
|
||||
isDimmedByFocus,
|
||||
isEducationAnchor,
|
||||
onToggle,
|
||||
onViewFull,
|
||||
@@ -34,6 +36,7 @@ function TimelineInterventionItem({
|
||||
<ExpandableCardShell
|
||||
isExpanded={isExpanded}
|
||||
isHighlighted={isHighlightedFromGraph}
|
||||
isDimmedByFocus={isDimmedByFocus}
|
||||
accentColor={entity.orgColor}
|
||||
onToggle={onToggle}
|
||||
ariaLabel={`${entity.title} at ${entity.organization}, ${entity.dateRange.display}. Click to ${isExpanded ? 'collapse' : 'expand'} details.`}
|
||||
@@ -254,9 +257,10 @@ function TimelineInterventionItem({
|
||||
interface TimelineInterventionsSubsectionProps {
|
||||
onNodeHighlight?: (id: string | null) => void
|
||||
highlightedRoleId?: string | null
|
||||
focusRelatedIds?: Set<string> | null
|
||||
}
|
||||
|
||||
export function TimelineInterventionsSubsection({ onNodeHighlight, highlightedRoleId }: TimelineInterventionsSubsectionProps) {
|
||||
export function TimelineInterventionsSubsection({ onNodeHighlight, highlightedRoleId, focusRelatedIds }: TimelineInterventionsSubsectionProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const { openPanel } = useDetailPanel()
|
||||
|
||||
@@ -288,6 +292,7 @@ export function TimelineInterventionsSubsection({ onNodeHighlight, highlightedRo
|
||||
entity={entity}
|
||||
isExpanded={expandedId === entity.id}
|
||||
isHighlightedFromGraph={highlightedRoleId === entity.id}
|
||||
isDimmedByFocus={focusRelatedIds !== null && focusRelatedIds !== undefined && !focusRelatedIds.has(entity.id)}
|
||||
isEducationAnchor={entity.id === firstEducationId}
|
||||
onToggle={() => handleToggle(entity.id)}
|
||||
onViewFull={() => handleViewFull(entity)}
|
||||
|
||||
@@ -27,6 +27,7 @@ interface CareerConstellationProps {
|
||||
highlightedNodeId?: string | null
|
||||
containerHeight?: number | null
|
||||
animationReady?: boolean
|
||||
globalFocusActive?: boolean
|
||||
}
|
||||
|
||||
const nodeById = new Map(constellationNodes.map(node => [node.id, node]))
|
||||
@@ -39,6 +40,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
highlightedNodeId,
|
||||
containerHeight,
|
||||
animationReady = false,
|
||||
globalFocusActive = false,
|
||||
}) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -301,6 +303,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
||||
role="img"
|
||||
aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline"
|
||||
className={globalFocusActive || highlightedNodeId || pinnedNodeId ? 'constellation-focus-active' : ''}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
|
||||
@@ -42,9 +42,7 @@ export function useConstellationInteraction(deps: {
|
||||
if (supportsCoarsePointer) return
|
||||
deps.pauseForInteraction?.()
|
||||
deps.highlightGraphRef.current?.(d.id)
|
||||
if (d.type !== 'skill') {
|
||||
deps.callbacksRef.current.onNodeHover?.(d.id)
|
||||
}
|
||||
deps.callbacksRef.current.onNodeHover?.(d.id)
|
||||
})
|
||||
|
||||
nodeSelection.on('mouseleave.interaction', function() {
|
||||
|
||||
@@ -494,6 +494,27 @@ html {
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ===== CONSTELLATION FOCUS MODE — axis/background dimming ===== */
|
||||
svg.constellation-focus-active .axis-line,
|
||||
svg.constellation-focus-active .year-tick {
|
||||
stroke-opacity: 0.25;
|
||||
transition: stroke-opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
svg.constellation-focus-active .year-label {
|
||||
opacity: 0.25;
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
svg:not(.constellation-focus-active) .axis-line,
|
||||
svg:not(.constellation-focus-active) .year-tick {
|
||||
transition: stroke-opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
svg:not(.constellation-focus-active) .year-label {
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
/* ===== FOCUS VISIBLE STYLES (WCAG Compliance) ===== */
|
||||
/* Default focus ring for all focusable elements */
|
||||
*:focus-visible {
|
||||
@@ -593,6 +614,16 @@ textarea:focus-visible {
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* No transition for constellation focus mode axis dimming */
|
||||
svg.constellation-focus-active .axis-line,
|
||||
svg.constellation-focus-active .year-tick,
|
||||
svg.constellation-focus-active .year-label,
|
||||
svg:not(.constellation-focus-active) .axis-line,
|
||||
svg:not(.constellation-focus-active) .year-tick,
|
||||
svg:not(.constellation-focus-active) .year-label {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Instant constellation fullscreen */
|
||||
@keyframes constellation-fullscreen-in {
|
||||
from { transform: none; opacity: 1; }
|
||||
|
||||
Reference in New Issue
Block a user