11import { useEffect , useRef , useState } from 'react' ;
2- import { IoChevronUpOutline , IoTimeOutline } from 'react-icons/io5' ;
2+ import { IoChevronUpOutline } from 'react-icons/io5' ;
33import { colors } from '../../../../theme/colors.js' ;
4+ import { TimelineHeader } from './TimelineHeader.js' ;
45import type { TimelineMoment } from './TimelineBar.js' ;
56
67interface SparklineProps {
@@ -24,10 +25,13 @@ interface ContextMenuState {
2425}
2526
2627/**
27- * Collapsed timeline strip — a sparkline of version dots with title + date below
28- * each. Single mode highlights the viewed version; compare mode shows progress
29- * between FROM and TO. Left-click navigates; right-click opens a compare-from /
30- * compare-to menu.
28+ * Collapsed timeline strip — a "Browse versions" header (with the current-version
29+ * pill) above a sparkline of version dots with title + date below each. Single
30+ * mode highlights the viewed version; compare mode shows progress between FROM and
31+ * TO. Left-click navigates; right-click opens a compare-from / compare-to menu.
32+ *
33+ * A single-version resource collapses to just the header + version pill — there is
34+ * no meaningful track to scrub, so an inert one-dot slider is suppressed.
3135 */
3236export function Sparkline ( {
3337 moments,
@@ -45,6 +49,7 @@ export function Sparkline({
4549
4650 const comparing = compareFrom !== null && compareTo !== null ;
4751 const total = moments . length ;
52+ const singleVersion = total <= 1 ;
4853 // Evenly distribute dots from left to right. With a single moment, centre it.
4954 const pct = ( i : number ) => ( total <= 1 ? 50 : ( i / ( total - 1 ) ) * 100 ) ;
5055
@@ -109,147 +114,145 @@ export function Sparkline({
109114 setMenu ( null ) ;
110115 } ;
111116
117+ const newPill = showNewPill ? (
118+ < span
119+ data-testid = "timeline-new-pill"
120+ className = "font-inter rounded-full text-white"
121+ style = { { backgroundColor : colors . new , fontSize : 9.5 , fontWeight : 700 , padding : '2px 6px' } }
122+ >
123+ NEW
124+ </ span >
125+ ) : null ;
126+
127+ const expandButton = (
128+ < button
129+ type = "button"
130+ onClick = { handleExpand }
131+ aria-label = "Expand timeline"
132+ title = "Expand timeline"
133+ className = "shrink-0 flex items-center justify-center bg-transparent border-0 p-0 cursor-pointer"
134+ style = { { width : 28 , height : 28 } }
135+ >
136+ < IoChevronUpOutline size = { 14 } style = { { color : colors . ink [ 500 ] , strokeWidth : 2 } } />
137+ </ button >
138+ ) ;
139+
112140 return (
113141 < div
114142 data-testid = "timeline-bar-collapsed"
115- className = "flex items-start gap-4 bg-white border-t font-inter"
116- style = { { borderTopColor : colors . ink [ 200 ] , padding : '12px 20px 14px' } }
143+ className = "flex flex-col bg-white border-t font-inter"
144+ style = { { borderTopColor : colors . ink [ 200 ] , padding : '12px 20px 14px' , gap : 8 } }
117145 >
118- { /* Left cluster: clock + Timeline + (optional) NEW pill */ }
119- < div className = "flex items-center gap-2 shrink-0" style = { { paddingTop : 4 } } >
120- < IoTimeOutline size = { 14 } style = { { color : colors . ink [ 500 ] , strokeWidth : 2 } } />
121- < span
122- className = "font-inter"
123- style = { { fontSize : 12 , fontWeight : 500 , color : colors . ink [ 500 ] } }
124- >
125- Timeline
126- </ span >
127- { showNewPill && (
128- < span
129- data-testid = "timeline-new-pill"
130- className = "font-inter rounded-full text-white"
131- style = { {
132- backgroundColor : colors . new ,
133- fontSize : 9.5 ,
134- fontWeight : 700 ,
135- padding : '2px 6px' ,
136- } }
137- >
138- NEW
139- </ span >
140- ) }
146+ { /* Header row: purpose + version pill on the left, NEW pill + expand on the right. */ }
147+ < div className = "flex items-center gap-2" >
148+ < TimelineHeader currentVersion = { currentVersion } > { newPill } </ TimelineHeader >
149+ < div className = "ml-auto" > { expandButton } </ div >
141150 </ div >
142151
143- { /* Centre: sparkline track with dots + labels. overflow-hidden clips
144- long labels at the track edge so they can never paint over the
145- statically-positioned expand button (#2728). The height must fully
146- contain the dot + label + date stack, otherwise the same clip would
147- cut off the date row beneath each dot. */ }
148- < div
149- data-testid = "timeline-sparkline-track"
150- className = "relative flex-1"
151- style = { { height : 72 , overflow : 'hidden' } }
152- >
153- { /* Inner track wrapper inset 10px each side so dot percentages map directly */ }
154- < div className = "absolute" style = { { left : 10 , right : 10 , top : 0 , bottom : 0 } } >
155- < div
156- className = "absolute"
157- style = { { left : 0 , right : 0 , top : 18 , height : 2 , backgroundColor : colors . ink [ 200 ] } }
158- />
159- { progressRight > progressLeft && (
152+ { /* Track row. A single-version resource has nothing to scrub, so the
153+ track is suppressed (the version pill already states what's shown).
154+ overflow-hidden clips long labels at the track edge (#2728). */ }
155+ { ! singleVersion && (
156+ < div
157+ data-testid = "timeline-sparkline-track"
158+ className = "relative"
159+ style = { { height : 72 , overflow : 'hidden' } }
160+ >
161+ { /* Inner track wrapper inset 10px each side so dot percentages map directly */ }
162+ < div className = "absolute" style = { { left : 10 , right : 10 , top : 0 , bottom : 0 } } >
160163 < div
161- data-testid = "timeline-progress"
162164 className = "absolute"
163- style = { {
164- left : `${ progressLeft } %` ,
165- width : `${ progressRight - progressLeft } %` ,
166- top : 18 ,
167- height : 2 ,
168- backgroundColor : colors . calm . blue ,
169- } }
165+ style = { { left : 0 , right : 0 , top : 15 , height : 2 , backgroundColor : colors . ink [ 200 ] } }
170166 />
171- ) }
172- { moments . map ( ( moment , i ) => {
173- // Highlight the dot for whatever the diagram is currently showing
174- // (the viewed version). In compare mode the two endpoints are
175- // highlighted via isFrom / isTo below instead.
176- const isCurrent = ! comparing && i === viewedIdx ;
177- const isFrom = comparing && i === fromIdx ;
178- const isTo = comparing && i === toIdx ;
179- const isActive = isCurrent || isFrom || isTo ;
180- const dotSize = isActive ? 22 : 14 ;
181- const dotMt = isActive ? 8 : 12 ;
182- return (
167+ { progressRight > progressLeft && (
183168 < div
184- key = { moment . key }
185- className = "absolute top-0 flex flex-col items-center"
186- style = { { left : `${ pct ( i ) } %` , transform : 'translateX(-50%)' } }
187- >
188- < button
189- type = "button"
190- aria-label = { `Moment ${ moment . label } ` }
191- aria-current = { isCurrent ? 'true' : undefined }
192- aria-pressed = { isFrom || isTo ? true : undefined }
193- onClick = { ( ) => handleDotClick ( moment ) }
194- onContextMenu = { ( e ) => handleDotContextMenu ( e , moment ) }
195- className = "bg-transparent p-0 border-0 cursor-pointer flex justify-center"
196- style = { { width : 32 , height : 32 } }
169+ data-testid = "timeline-progress"
170+ className = "absolute"
171+ style = { {
172+ left : `${ progressLeft } %` ,
173+ width : `${ progressRight - progressLeft } %` ,
174+ top : 15 ,
175+ height : 2 ,
176+ backgroundColor : colors . redesign . primary ,
177+ } }
178+ />
179+ ) }
180+ { moments . map ( ( moment , i ) => {
181+ // Highlight the dot for whatever the diagram is currently showing
182+ // (the viewed version). In compare mode the two endpoints are
183+ // highlighted via isFrom / isTo below instead.
184+ const isCurrent = ! comparing && i === viewedIdx ;
185+ const isFrom = comparing && i === fromIdx ;
186+ const isTo = comparing && i === toIdx ;
187+ const isActive = isCurrent || isFrom || isTo ;
188+ // Active = filled 12px blue dot with a halo; others = 9px white
189+ // dots with a slate ring (redesign #4 anatomy).
190+ const dotSize = isActive ? 12 : 9 ;
191+ return (
192+ < div
193+ key = { moment . key }
194+ className = "absolute top-0 flex flex-col items-center"
195+ style = { { left : `${ pct ( i ) } %` , transform : 'translateX(-50%)' } }
197196 >
197+ < button
198+ type = "button"
199+ aria-label = { `Moment ${ moment . label } ` }
200+ aria-current = { isCurrent ? 'true' : undefined }
201+ aria-pressed = { isFrom || isTo ? true : undefined }
202+ onClick = { ( ) => handleDotClick ( moment ) }
203+ onContextMenu = { ( e ) => handleDotContextMenu ( e , moment ) }
204+ className = "bg-transparent p-0 border-0 cursor-pointer flex items-center justify-center"
205+ style = { { width : 32 , height : 32 } }
206+ >
207+ < div
208+ data-active = { isActive ? 'true' : undefined }
209+ style = { {
210+ width : dotSize ,
211+ height : dotSize ,
212+ borderRadius : 999 ,
213+ backgroundColor : isActive ? colors . redesign . primary : '#ffffff' ,
214+ border : isActive
215+ ? `2px solid ${ colors . redesign . primary } `
216+ : `1.5px solid ${ colors . ink [ 400 ] } ` ,
217+ boxShadow : isActive
218+ ? `0 0 0 4px ${ colors . redesign . primary } 33`
219+ : 'none' ,
220+ } }
221+ />
222+ </ button >
198223 < div
224+ className = "font-inter"
199225 style = { {
200- width : dotSize ,
201- height : dotSize ,
202- borderRadius : 999 ,
203- backgroundColor : isActive ? colors . calm . blue : '#ffffff' ,
204- border : `2px solid ${ isActive ? colors . calm . blue : colors . ink [ 300 ] } ` ,
205- marginTop : dotMt ,
226+ marginTop : 4 ,
227+ fontSize : 12 ,
228+ fontWeight : isActive ? 600 : 500 ,
229+ color : isActive ? colors . ink [ 900 ] : colors . ink [ 700 ] ,
230+ textAlign : 'center' ,
231+ // Bound + truncate long names so the strip stays
232+ // readable; the title tooltip shows the full name (#2728).
233+ maxWidth : 120 ,
234+ overflow : 'hidden' ,
235+ textOverflow : 'ellipsis' ,
236+ whiteSpace : 'nowrap' ,
206237 } }
207- />
208- </ button >
209- < div
210- className = "font-inter"
211- style = { {
212- marginTop : 4 ,
213- fontSize : 12 ,
214- fontWeight : isActive ? 600 : 500 ,
215- color : isActive ? colors . ink [ 900 ] : colors . ink [ 700 ] ,
216- textAlign : 'center' ,
217- // Bound + truncate long names so the strip stays
218- // readable; the title tooltip shows the full name (#2728).
219- maxWidth : 120 ,
220- overflow : 'hidden' ,
221- textOverflow : 'ellipsis' ,
222- whiteSpace : 'nowrap' ,
223- } }
224- title = { moment . label }
225- >
226- { moment . label }
227- </ div >
228- { moment . validFrom && (
229- < div
230- className = "font-mono-jb"
231- style = { { fontSize : 10 , color : colors . ink [ 400 ] } }
238+ title = { moment . label }
232239 >
233- { moment . validFrom }
240+ { moment . label }
234241 </ div >
235- ) }
236- </ div >
237- ) ;
238- } ) }
242+ { moment . validFrom && (
243+ < div
244+ className = "font-mono-jb"
245+ style = { { fontSize : 10 , color : colors . ink [ 400 ] } }
246+ >
247+ { moment . validFrom }
248+ </ div >
249+ ) }
250+ </ div >
251+ ) ;
252+ } ) }
253+ </ div >
239254 </ div >
240- </ div >
241-
242- { /* Right cluster: chevron-up to expand */ }
243- < button
244- type = "button"
245- onClick = { handleExpand }
246- aria-label = "Expand timeline"
247- title = "Expand timeline"
248- className = "shrink-0 flex items-center justify-center bg-transparent border-0 p-0 cursor-pointer"
249- style = { { width : 28 , height : 28 } }
250- >
251- < IoChevronUpOutline size = { 14 } style = { { color : colors . ink [ 500 ] , strokeWidth : 2 } } />
252- </ button >
255+ ) }
253256
254257 { menu && (
255258 < div
0 commit comments