Skip to content

Commit 7646122

Browse files
committed
fix: two-row progress bar layout — dots+connectors separate from labels
Labels now sit in their own row with a 72px centered span under each dot. Connector lines stay at fixed dot-center height regardless of label length. Labels use word-break and hyphens:auto for long translations.
1 parent 9aa5bbd commit 7646122

1 file changed

Lines changed: 113 additions & 91 deletions

File tree

src/Progress.tsx

Lines changed: 113 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type {CSSProperties} from 'react';
1+
import React from 'react';
22

33
export interface Step {
44
index: number;
@@ -14,65 +14,66 @@ interface ProgressProps {
1414

1515
type StepState = 'completed' | 'current' | 'upcoming';
1616

17-
const COLORS = {
17+
const DOT_SIZE = 32;
18+
19+
const COLOR = {
1820
completed: '#22c55e',
1921
current: '#38bdf8',
20-
upcoming: 'rgba(255,255,255,0.2)',
21-
line: 'rgba(255,255,255,0.15)',
22-
lineFilled: '#22c55e',
23-
text: 'rgba(255,255,255,0.9)',
24-
textDim: 'rgba(255,255,255,0.4)',
22+
upcoming: 'transparent',
23+
lineActive: '#22c55e',
24+
lineInactive: 'rgba(255,255,255,0.15)',
25+
textBright: 'rgba(255,255,255,0.9)',
26+
textDim: 'rgba(255,255,255,0.55)',
2527
};
2628

27-
function stepState(index: number, current: number, completed: ReadonlySet<number>): StepState {
29+
function getState(
30+
index: number,
31+
current: number,
32+
completed: ReadonlySet<number>,
33+
): StepState {
2834
if (completed.has(index)) return 'completed';
2935
if (index === current) return 'current';
3036
return 'upcoming';
3137
}
3238

3339
function Dot({state, number}: {state: StepState; number: number}) {
34-
const base: CSSProperties = {
35-
width: 32,
36-
height: 32,
40+
const shared = {
41+
width: DOT_SIZE,
42+
height: DOT_SIZE,
3743
borderRadius: '50%',
3844
display: 'flex',
3945
alignItems: 'center',
4046
justifyContent: 'center',
4147
fontFamily: 'sans-serif',
4248
fontWeight: 700,
4349
fontSize: 13,
44-
flexShrink: 0,
4550
transition: 'background 0.25s, box-shadow 0.25s',
46-
};
51+
flexShrink: 0,
52+
} as const;
4753

4854
if (state === 'completed') {
49-
return (
50-
<div style={{...base, background: COLORS.completed, color: '#fff'}}>
51-
52-
</div>
53-
);
55+
return <div style={{...shared, background: COLOR.completed, color: '#fff'}}></div>;
5456
}
5557
if (state === 'current') {
5658
return (
5759
<div
5860
style={{
59-
...base,
60-
background: COLORS.current,
61+
...shared,
62+
background: COLOR.current,
6163
color: '#fff',
62-
boxShadow: `0 0 0 4px rgba(56,189,248,0.3)`,
64+
boxShadow: '0 0 0 4px rgba(56,189,248,0.3)',
6365
}}
6466
>
6567
{number}
6668
</div>
6769
);
6870
}
69-
// Upcoming: white outline — looks like an unselected tab, not a locked step.
7071
return (
7172
<div
7273
style={{
73-
...base,
74-
background: 'transparent',
75-
border: `2px solid rgba(255,255,255,0.5)`,
74+
...shared,
75+
background: COLOR.upcoming,
76+
border: '2px solid rgba(255,255,255,0.5)',
7677
color: 'rgba(255,255,255,0.7)',
7778
}}
7879
>
@@ -81,7 +82,12 @@ function Dot({state, number}: {state: StepState; number: number}) {
8182
);
8283
}
8384

84-
export default function Progress({steps, currentIndex, completedIndices, onNavigate}: ProgressProps) {
85+
export default function Progress({
86+
steps,
87+
currentIndex,
88+
completedIndices,
89+
onNavigate,
90+
}: ProgressProps) {
8591
return (
8692
<div
8793
style={{
@@ -92,83 +98,99 @@ export default function Progress({steps, currentIndex, completedIndices, onNavig
9298
zIndex: 50,
9399
background: 'rgba(2,0,28,0.85)',
94100
backdropFilter: 'blur(8px)',
95-
padding: '10px 24px 14px',
101+
padding: '10px 24px 12px',
96102
display: 'flex',
97103
alignItems: 'center',
98104
justifyContent: 'center',
99105
}}
100106
>
101-
<div
102-
style={{
103-
display: 'flex',
104-
alignItems: 'flex-start',
105-
gap: 0,
106-
maxWidth: 600,
107-
width: '100%',
108-
}}
109-
>
110-
{steps.map((step, i) => {
111-
const state = stepState(step.index, currentIndex, completedIndices);
112-
const isLast = i === steps.length - 1;
113-
114-
return (
115-
<div
116-
key={step.index}
117-
style={{display: 'flex', alignItems: 'flex-start', flex: isLast ? 0 : 1}}
118-
>
119-
{/* Step */}
120-
<button
121-
data-testid={`step-${step.index}`}
122-
data-state={stepState(step.index, currentIndex, completedIndices)}
123-
onClick={() => onNavigate(step.index)}
124-
title={step.label}
125-
style={{
126-
background: 'none',
127-
border: 'none',
128-
cursor: 'pointer',
129-
padding: 0,
130-
display: 'flex',
131-
flexDirection: 'column',
132-
alignItems: 'center',
133-
gap: 4,
134-
flexShrink: 0,
135-
}}
136-
>
137-
<Dot state={state} number={step.index + 1} />
138-
<span
107+
<div style={{width: '100%', maxWidth: 640}}>
108+
{/* Row 1: dots + connector lines — fixed height, connectors stay centered */}
109+
<div style={{display: 'flex', alignItems: 'center'}}>
110+
{steps.map((step, i) => {
111+
const state = getState(step.index, currentIndex, completedIndices);
112+
const isLast = i === steps.length - 1;
113+
return (
114+
<React.Fragment key={step.index}>
115+
<button
116+
data-testid={`step-${step.index}`}
117+
data-state={state}
118+
onClick={() => onNavigate(step.index)}
139119
style={{
140-
fontSize: 10,
141-
fontFamily: 'sans-serif',
142-
color: state === 'upcoming' ? 'rgba(255,255,255,0.6)' : COLORS.text,
143-
whiteSpace: 'nowrap',
144-
maxWidth: 72,
145-
textAlign: 'center',
146-
lineHeight: 1.2,
147-
transition: 'color 0.25s',
120+
background: 'none',
121+
border: 'none',
122+
cursor: 'pointer',
123+
padding: 0,
124+
flexShrink: 0,
125+
lineHeight: 0,
148126
}}
127+
aria-label={step.label}
149128
>
150-
{step.label}
151-
</span>
152-
</button>
129+
<Dot state={state} number={step.index + 1} />
130+
</button>
131+
{!isLast && (
132+
<div
133+
style={{
134+
flex: 1,
135+
height: 2,
136+
background: completedIndices.has(step.index)
137+
? COLOR.lineActive
138+
: COLOR.lineInactive,
139+
transition: 'background 0.4s',
140+
minWidth: 12,
141+
}}
142+
/>
143+
)}
144+
</React.Fragment>
145+
);
146+
})}
147+
</div>
153148

154-
{/* Connector line */}
155-
{!isLast && (
149+
{/* Row 2: labels centered under each dot */}
150+
<div
151+
style={{
152+
display: 'flex',
153+
alignItems: 'flex-start',
154+
marginTop: 6,
155+
}}
156+
>
157+
{steps.map((step, i) => {
158+
const state = getState(step.index, currentIndex, completedIndices);
159+
const isLast = i === steps.length - 1;
160+
return (
161+
<React.Fragment key={step.index}>
162+
{/* Label occupies the same width as the dot so it centers under it */}
156163
<div
157164
style={{
158-
flex: 1,
159-
height: 2,
160-
marginTop: 15,
161-
background: completedIndices.has(step.index)
162-
? COLORS.lineFilled
163-
: COLORS.line,
164-
transition: 'background 0.4s',
165-
minWidth: 16,
165+
width: DOT_SIZE,
166+
flexShrink: 0,
167+
display: 'flex',
168+
justifyContent: 'center',
166169
}}
167-
/>
168-
)}
169-
</div>
170-
);
171-
})}
170+
>
171+
<span
172+
style={{
173+
display: 'block',
174+
width: 72,
175+
textAlign: 'center',
176+
fontSize: 10,
177+
fontFamily: 'sans-serif',
178+
lineHeight: 1.3,
179+
color: state === 'upcoming' ? COLOR.textDim : COLOR.textBright,
180+
transition: 'color 0.25s',
181+
wordBreak: 'break-word',
182+
hyphens: 'auto',
183+
}}
184+
>
185+
{step.label}
186+
</span>
187+
</div>
188+
{/* Spacer that mirrors the connector line so labels stay aligned */}
189+
{!isLast && <div style={{flex: 1, minWidth: 12}} />}
190+
</React.Fragment>
191+
);
192+
})}
193+
</div>
172194
</div>
173195
</div>
174196
);

0 commit comments

Comments
 (0)