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