feat: US-006 - Mobile accordion expansion for role details

This commit is contained in:
2026-02-16 10:04:35 +00:00
parent 67fe5567a9
commit a258706bf3
3 changed files with 205 additions and 2 deletions
+1 -1
View File
@@ -117,7 +117,7 @@
"Verify in browser at mobile viewport: tap role → accordion expands with details, tap again → collapses"
],
"priority": 6,
"passes": false,
"passes": true,
"notes": "New JSX inside CareerConstellation container div, below the SVG and HTML legend. Import consultations from '@/data/consultations'. When pinnedNodeId matches a consultation.id on a coarse pointer device, render the accordion. Use a local showMore state for the expand toggle. Consultation data provides: role (title), organization, duration, examination (string[] achievements), plan (string[] outcomes). Show first 3 examination items collapsed, all when expanded. Animation: use max-height + overflow hidden with CSS transition (200ms ease-out), or measure content height dynamically. Add click handler on SVG background rect to clear pinnedNodeId for 'tap elsewhere to close'. Hide accordion entirely when !supportsCoarsePointer. Style with the same font and spacing as WorkExperienceSubsection for consistency. Use the d3-viz skill."
},
{
+23
View File
@@ -29,6 +29,8 @@
- Use the d3-viz skill for all D3 rendering stories
- Consultation entries ordered reverse-chronologically (newest first) — new entries go at the end of the array
- Constellation role nodes, skill mappings, and links are in constellation.ts — adding nodes there automatically extends yScale domain and screen reader description
- Mobile accordion (coarse pointer): pinnedNodeId drives both graph highlight AND accordion visibility. Accordion only shows for role-type nodes (not skills)
- SVG background rect has class `.bg-rect` — used for "tap elsewhere to close" handler on touch devices
## 2026-02-16 - US-001
- Added Duty Pharmacy Manager (2016-2017, Tesco PLC) and Pre-Registration Pharmacist (2015-2016, Paydens Pharmacy) role nodes to constellation.ts
@@ -110,3 +112,24 @@
- mouseleave should reset to highlightedNodeId (external prop) not pinnedNodeId — on desktop there is no pin, so fallback is null (resting)
- The supportsCoarsePointer guard at top of each handler cleanly separates desktop/touch paths without duplicating the handler
---
## 2026-02-16 - US-006
- Added mobile accordion expansion below constellation SVG for role details on tap
- Accordion shows role title, organisation, duration, and top 3 examination items by default
- "Show more" button reveals full examination and plan arrays (only appears when >3 examination items)
- Tapping a different role switches accordion content and auto-collapses "show more" (via useEffect on pinnedNodeId)
- Tapping the same role again or tapping empty SVG background collapses accordion and resets highlights
- Added click handler on SVG background rect (`.bg-rect`) to clear pinnedNodeId on coarse pointer
- Accordion uses Framer Motion AnimatePresence with height 0→auto, 200ms ease-out (matches tile expansion pattern)
- Accordion hidden entirely on desktop (fine pointer) via supportsCoarsePointer guard
- Skill node taps do not open accordion — only role nodes (filtered by `n.type === 'role'`)
- Legend hint text changes to "Tap to explore connections" on coarse pointer devices
- Files changed: src/components/CareerConstellation.tsx, Ralph/prd.json, Ralph/progress.txt
- **Learnings for future iterations:**
- The SVG background rect must have a class (`.bg-rect`) for later selection — D3 event handlers on SVG elements created early in the useEffect can reference functions defined later by selecting the element after the function is defined
- pinnedNodeId is local to CareerConstellation — it's not passed to DashboardLayout. The accordion relies on this internal state
- Framer Motion `key` prop on motion.div enables smooth exit→enter transitions when switching between different roles (AnimatePresence exits the old key, enters the new)
- `accordionShowMore` state must reset on pinnedNodeId change to auto-collapse "show more" when switching roles
- Not all consultations have >3 examination items — the "Show more" button only renders conditionally, and plan items are only shown when expanded
- Browser testing for coarse pointer features requires touch emulation — Playwright's default Chromium reports fine pointer, so the accordion won't appear without explicit touch device emulation
---
+181 -1
View File
@@ -1,6 +1,8 @@
import React, { useRef, useEffect, useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import * as d3 from 'd3'
import { constellationNodes, constellationLinks, roleSkillMappings } from '@/data/constellation'
import { consultations } from '@/data/consultations'
import type { ConstellationNode } from '@/types/pmr'
interface CareerConstellationProps {
@@ -107,6 +109,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT, scaleFactor: 1 })
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
const [pinnedNodeId, setPinnedNodeId] = useState<string | null>(null)
const [accordionShowMore, setAccordionShowMore] = useState(false)
const [nodeButtonPositions, setNodeButtonPositions] = useState<Record<string, { x: number; y: number }>>({})
callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover }
@@ -186,6 +189,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.range([topPadding, height - bottomPadding])
svg.append('rect')
.attr('class', 'bg-rect')
.attr('width', width)
.attr('height', height)
.attr('fill', 'var(--surface)')
@@ -551,6 +555,15 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
highlightGraphRef.current = applyGraphHighlight
// Touch: tap on background to clear pinned highlight and close accordion
svg.select('.bg-rect').on('click', () => {
if (supportsCoarsePointer) {
setPinnedNodeId(null)
applyGraphHighlight(null)
callbacksRef.current.onNodeHover?.(null)
}
})
nodeSelection.on('mouseenter', function(_event, d) {
if (supportsCoarsePointer) return
applyGraphHighlight(d.id)
@@ -706,6 +719,16 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
highlightGraphRef.current(highlightedNodeId ?? pinnedNodeId)
}, [highlightedNodeId, pinnedNodeId])
// Reset "show more" when switching between pinned roles
useEffect(() => {
setAccordionShowMore(false)
}, [pinnedNodeId])
// Find consultation for pinned role (accordion on mobile)
const pinnedRoleNode = pinnedNodeId ? constellationNodes.find(n => n.id === pinnedNodeId && n.type === 'role') : null
const pinnedConsultation = pinnedRoleNode ? consultations.find(c => c.id === pinnedRoleNode.id) : null
const showAccordion = supportsCoarsePointer && pinnedConsultation !== null && pinnedConsultation !== undefined
return (
<div
ref={containerRef}
@@ -765,9 +788,166 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
</React.Fragment>
))}
<span style={{ color: 'var(--border)', userSelect: 'none' }} aria-hidden="true">·</span>
<span style={{ opacity: 0.7 }}>Hover to explore connections</span>
<span style={{ opacity: 0.7 }}>{supportsCoarsePointer ? 'Tap to explore connections' : 'Hover to explore connections'}</span>
</div>
{/* Mobile accordion: role details on tap */}
<AnimatePresence>
{showAccordion && pinnedConsultation && (
<motion.div
key={pinnedConsultation.id}
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }}
style={{ overflow: 'hidden' }}
>
<div
style={{
padding: '12px 16px',
borderTop: `1px solid ${pinnedConsultation.orgColor ?? 'var(--border-light)'}`,
fontFamily: 'var(--font-ui)',
}}
>
<div style={{ marginBottom: '8px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '2px',
}}
>
<span
style={{
display: 'inline-block',
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: pinnedConsultation.orgColor ?? 'var(--accent)',
flexShrink: 0,
}}
/>
<span
style={{
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-primary)',
}}
>
{pinnedConsultation.role}
</span>
</div>
<div
style={{
fontSize: '11px',
color: 'var(--text-secondary)',
fontFamily: 'var(--font-geist-mono)',
paddingLeft: '14px',
}}
>
{pinnedConsultation.organization} · {pinnedConsultation.duration}
</div>
</div>
<ul
style={{
margin: 0,
paddingLeft: '14px',
listStyle: 'none',
}}
>
{(accordionShowMore ? pinnedConsultation.examination : pinnedConsultation.examination.slice(0, 3)).map((item, i) => (
<li
key={i}
style={{
fontSize: '12px',
color: 'var(--text-secondary)',
lineHeight: '1.5',
marginBottom: '4px',
display: 'flex',
gap: '8px',
}}
>
<span
style={{
display: 'inline-block',
width: '4px',
height: '4px',
borderRadius: '50%',
backgroundColor: pinnedConsultation.orgColor ?? 'var(--accent)',
opacity: 0.5,
flexShrink: 0,
marginTop: '7px',
}}
/>
{item}
</li>
))}
</ul>
{accordionShowMore && pinnedConsultation.plan.length > 0 && (
<ul
style={{
margin: '8px 0 0',
paddingLeft: '14px',
listStyle: 'none',
}}
>
{pinnedConsultation.plan.map((item, i) => (
<li
key={i}
style={{
fontSize: '12px',
color: 'var(--text-tertiary)',
lineHeight: '1.5',
marginBottom: '4px',
display: 'flex',
gap: '8px',
}}
>
<span
style={{
display: 'inline-block',
width: '4px',
height: '4px',
borderRadius: '50%',
backgroundColor: 'var(--text-tertiary)',
opacity: 0.4,
flexShrink: 0,
marginTop: '7px',
}}
/>
{item}
</li>
))}
</ul>
)}
{pinnedConsultation.examination.length > 3 && (
<button
type="button"
onClick={() => setAccordionShowMore(prev => !prev)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px 14px',
fontSize: '11px',
fontFamily: 'var(--font-geist-mono)',
color: pinnedConsultation.orgColor ?? 'var(--accent)',
fontWeight: 500,
marginTop: '4px',
}}
>
{accordionShowMore ? 'Show less' : 'Show more'}
</button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
<p
style={{
position: 'absolute',