Task 8: Rebuild ConsultationsView (Experience view)

Rebuilt from ref-consultations.md spec with Clinical Luxury styling:
- Framer Motion height-only expand/collapse (no opacity fade)
- font-ui (Elvaro Grotesque) throughout, Geist Mono for dates/codes
- 3px left border color-coded by employer (NHS blue / Tesco teal)
- Multi-layered card shadows (shadow-pmr)
- Blue tint hover state (#EFF6FF)
- H/E/P section headers: uppercase, 12px, letter-spacing 0.05em
- Coded entries in Geist Mono with bracket codes
- Single-expand accordion behavior
- Chevron rotation via Framer Motion
- Proper font sizes per spec (13px body, 15px titles, 12px codes)
- Focus-visible ring on entry buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 00:51:23 +00:00
parent 1d8cb78143
commit 2be346144c
+89 -91
View File
@@ -1,8 +1,11 @@
import { useState, useRef, useEffect } from 'react' import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronDown } from 'lucide-react' import { ChevronDown } from 'lucide-react'
import { consultations } from '@/data/consultations' import { consultations } from '@/data/consultations'
import type { Consultation, ViewId } from '@/types/pmr' import type { Consultation, ViewId } from '@/types/pmr'
// ─── Props ──────────────────────────────────────────────────────────────────
interface ConsultationsViewProps { interface ConsultationsViewProps {
onNavigate?: (view: ViewId, itemId?: string) => void onNavigate?: (view: ViewId, itemId?: string) => void
initialExpandedId?: string initialExpandedId?: string
@@ -10,6 +13,7 @@ interface ConsultationsViewProps {
export function ConsultationsView({ initialExpandedId }: ConsultationsViewProps) { export function ConsultationsView({ initialExpandedId }: ConsultationsViewProps) {
const [expandedId, setExpandedId] = useState<string | null>(initialExpandedId ?? null) const [expandedId, setExpandedId] = useState<string | null>(initialExpandedId ?? null)
const prefersReducedMotion = typeof window !== 'undefined' const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false : false
@@ -21,10 +25,10 @@ export function ConsultationsView({ initialExpandedId }: ConsultationsViewProps)
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="font-inter font-semibold text-lg text-gray-900"> <h1 className="font-ui font-semibold text-[18px] text-gray-900">
Consultation History Consultation Journal
</h1> </h1>
<span className="font-geist text-xs text-gray-500"> <span className="font-geist text-[12px] text-gray-500">
{consultations.length} entries {consultations.length} entries
</span> </span>
</div> </div>
@@ -44,6 +48,8 @@ export function ConsultationsView({ initialExpandedId }: ConsultationsViewProps)
) )
} }
// ─── Consultation Entry ─────────────────────────────────────────────────────
interface ConsultationEntryProps { interface ConsultationEntryProps {
consultation: Consultation consultation: Consultation
isExpanded: boolean isExpanded: boolean
@@ -57,168 +63,159 @@ function ConsultationEntry({
onToggle, onToggle,
prefersReducedMotion, prefersReducedMotion,
}: ConsultationEntryProps) { }: ConsultationEntryProps) {
const contentRef = useRef<HTMLDivElement>(null)
const expandedContentRef = useRef<HTMLDivElement>(null)
const [height, setHeight] = useState<number | undefined>(isExpanded ? undefined : 0)
useEffect(() => {
if (prefersReducedMotion) {
setHeight(isExpanded ? undefined : 0)
return
}
if (isExpanded) {
const timer = setTimeout(() => {
setHeight(undefined)
}, 200)
return () => clearTimeout(timer)
}
setHeight(0)
}, [isExpanded, prefersReducedMotion])
useEffect(() => {
if (isExpanded && expandedContentRef.current) {
expandedContentRef.current.focus()
}
}, [isExpanded])
const keyCodedEntry = consultation.codedEntries[0] const keyCodedEntry = consultation.codedEntries[0]
return ( return (
<div <div
className="bg-white border border-gray-200 rounded overflow-hidden" className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden"
style={{ borderLeftWidth: '3px', borderLeftColor: consultation.orgColor }} style={{ borderLeftWidth: '3px', borderLeftColor: consultation.orgColor }}
> >
{/* Collapsed header — always visible */}
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
className="w-full px-4 py-3 flex items-start gap-3 text-left hover:bg-gray-50 transition-colors duration-100" className="w-full px-4 py-3 flex items-start gap-3 text-left hover:bg-[#EFF6FF] transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:ring-inset"
aria-expanded={isExpanded} aria-expanded={isExpanded}
aria-label={`${consultation.role} at ${consultation.organization}, ${consultation.date}`}
> >
<StatusDot isCurrent={consultation.isCurrent} /> <StatusDot isCurrent={consultation.isCurrent} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-geist text-sm text-gray-500">{consultation.date}</span> <span className="font-geist text-[13px] text-gray-500">
{consultation.date}
</span>
<span className="text-gray-300">|</span> <span className="text-gray-300">|</span>
<span <span
className="font-inter text-sm" className="font-ui text-[13px]"
style={{ color: consultation.orgColor }} style={{ color: consultation.orgColor }}
> >
{consultation.organization} {consultation.organization}
</span> </span>
</div> </div>
<h3 className="font-inter font-semibold text-base text-gray-900 mt-1">
<h3 className="font-ui font-semibold text-[15px] text-gray-900 mt-1">
{consultation.role} {consultation.role}
</h3> </h3>
{!isExpanded && keyCodedEntry && ( {!isExpanded && keyCodedEntry && (
<p className="font-inter text-sm text-gray-500 mt-1 line-clamp-1"> <p className="font-ui text-[13px] text-gray-500 mt-1 line-clamp-1">
<span className="text-gray-400">Key:</span>{' '} <span className="font-medium text-gray-400">Key:</span>{' '}
<span className="font-geist text-xs text-gray-400"> <span className="font-geist text-[12px] text-gray-400">
[{keyCodedEntry.code}] [{keyCodedEntry.code}]
</span>{' '} </span>{' '}
{keyCodedEntry.description} {keyCodedEntry.description}
</p> </p>
)} )}
</div> </div>
<ChevronDown
size={18} <motion.div
className={` animate={{ rotate: isExpanded ? 180 : 0 }}
flex-shrink-0 text-gray-400 transition-transform duration-200 mt-1 transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
${isExpanded ? 'rotate-180' : ''} className="flex-shrink-0 mt-1"
`} >
/> <ChevronDown size={18} className="text-gray-400" />
</motion.div>
</button> </button>
<div {/* Expandable content — height-only animation, NO opacity fade */}
ref={contentRef} <AnimatePresence initial={false}>
style={{
height: height !== undefined ? `${height}px` : 'auto',
transition: prefersReducedMotion ? 'none' : 'height 200ms ease-out',
overflow: 'hidden',
}}
>
{isExpanded && ( {isExpanded && (
<ExpandedContent <motion.div
consultation={consultation} key="expanded"
prefersReducedMotion={prefersReducedMotion} initial={{ height: 0 }}
contentRef={expandedContentRef} animate={{ height: 'auto' }}
/> exit={{ height: 0 }}
transition={{
duration: prefersReducedMotion ? 0 : 0.2,
ease: 'easeOut',
}}
className="overflow-hidden"
>
<ExpandedContent consultation={consultation} />
</motion.div>
)} )}
</div> </AnimatePresence>
</div> </div>
) )
} }
// ─── Status Dot ─────────────────────────────────────────────────────────────
interface StatusDotProps { interface StatusDotProps {
isCurrent: boolean isCurrent: boolean
} }
function StatusDot({ isCurrent }: StatusDotProps) { function StatusDot({ isCurrent }: StatusDotProps) {
return ( return (
<span className="flex-shrink-0 mt-1.5"> <span
className="flex-shrink-0 mt-1.5"
aria-label={isCurrent ? 'Current role' : 'Historical role'}
>
<span <span
className={` className={`block w-2 h-2 rounded-full ${
block w-2 h-2 rounded-full isCurrent ? 'bg-green-500' : 'bg-gray-400'
${isCurrent ? 'bg-green-500' : 'bg-gray-400'} }`}
`}
aria-label={isCurrent ? 'Current role' : 'Historical role'}
/> />
</span> </span>
) )
} }
// ─── Expanded Content ───────────────────────────────────────────────────────
interface ExpandedContentProps { interface ExpandedContentProps {
consultation: Consultation consultation: Consultation
prefersReducedMotion: boolean
contentRef: React.RefObject<HTMLDivElement>
} }
function ExpandedContent({ consultation, prefersReducedMotion, contentRef }: ExpandedContentProps) { function ExpandedContent({ consultation }: ExpandedContentProps) {
const opacity = prefersReducedMotion ? 1 : undefined
const transition = prefersReducedMotion ? 'none' : 'opacity 150ms ease-out'
return ( return (
<div <div className="px-4 pb-4">
ref={contentRef} <div className="pl-5 border-l border-[#E5E7EB] ml-1">
tabIndex={-1} {/* Duration */}
className="px-4 pb-4 outline-none"
style={{ opacity, transition }}
>
<div className="pl-5 border-l border-gray-200 ml-1">
<div className="mb-4"> <div className="mb-4">
<span className="font-inter text-sm text-gray-500">Duration: </span> <span className="font-ui text-[13px] text-gray-500">Duration: </span>
<span className="font-geist text-sm text-gray-700">{consultation.duration}</span> <span className="font-geist text-[13px] text-gray-700">
{consultation.duration}
</span>
</div> </div>
{/* HISTORY */}
<SectionHeader>HISTORY</SectionHeader> <SectionHeader>HISTORY</SectionHeader>
<p className="font-inter text-sm text-gray-700 leading-relaxed mb-4"> <p className="font-ui text-[13px] text-gray-700 leading-relaxed mb-4">
{consultation.history} {consultation.history}
</p> </p>
{/* EXAMINATION */}
<SectionHeader>EXAMINATION</SectionHeader> <SectionHeader>EXAMINATION</SectionHeader>
<ul className="space-y-1.5 mb-4"> <ul className="space-y-1.5 mb-4">
{consultation.examination.map((item, index) => ( {consultation.examination.map((item, index) => (
<li key={index} className="flex gap-2 text-sm"> <li key={index} className="flex gap-2 text-[13px]">
<span className="text-gray-300 flex-shrink-0">-</span> <span className="text-gray-300 flex-shrink-0">-</span>
<span className="font-inter text-gray-700">{item}</span> <span className="font-ui text-gray-700">{item}</span>
</li> </li>
))} ))}
</ul> </ul>
{/* PLAN */}
<SectionHeader>PLAN</SectionHeader> <SectionHeader>PLAN</SectionHeader>
<ul className="space-y-1.5 mb-4"> <ul className="space-y-1.5 mb-4">
{consultation.plan.map((item, index) => ( {consultation.plan.map((item, index) => (
<li key={index} className="flex gap-2 text-sm"> <li key={index} className="flex gap-2 text-[13px]">
<span className="text-gray-300 flex-shrink-0">-</span> <span className="text-gray-300 flex-shrink-0">-</span>
<span className="font-inter text-gray-700">{item}</span> <span className="font-ui text-gray-700">{item}</span>
</li> </li>
))} ))}
</ul> </ul>
{/* CODED ENTRIES */}
<SectionHeader>CODED ENTRIES</SectionHeader> <SectionHeader>CODED ENTRIES</SectionHeader>
<div className="space-y-1"> <div className="space-y-1">
{consultation.codedEntries.map(entry => ( {consultation.codedEntries.map(entry => (
<CodedEntry key={entry.code} code={entry.code} description={entry.description} /> <CodedEntry
key={entry.code}
code={entry.code}
description={entry.description}
/>
))} ))}
</div> </div>
</div> </div>
@@ -226,14 +223,18 @@ function ExpandedContent({ consultation, prefersReducedMotion, contentRef }: Exp
) )
} }
// ─── Section Header ─────────────────────────────────────────────────────────
function SectionHeader({ children }: { children: React.ReactNode }) { function SectionHeader({ children }: { children: React.ReactNode }) {
return ( return (
<h4 className="font-inter font-semibold text-xs uppercase tracking-wider text-gray-400 mb-2"> <h4 className="font-ui font-semibold text-[12px] uppercase tracking-[0.05em] text-gray-400 mb-2">
{children} {children}
</h4> </h4>
) )
} }
// ─── Coded Entry ────────────────────────────────────────────────────────────
interface CodedEntryProps { interface CodedEntryProps {
code: string code: string
description: string description: string
@@ -241,11 +242,8 @@ interface CodedEntryProps {
function CodedEntry({ code, description }: CodedEntryProps) { function CodedEntry({ code, description }: CodedEntryProps) {
return ( return (
<div className="flex items-start gap-2 text-sm"> <div className="font-geist text-[12px] text-gray-500">
<span className="font-geist text-xs text-gray-400 flex-shrink-0"> [{code}] {description}
[{code}]
</span>
<span className="font-inter text-gray-600">{description}</span>
</div> </div>
) )
} }