Skip to content

Commit 1451b85

Browse files
authored
feat: Add showScale prop to ProgressBar (#1020)
Add an optional `showScale` prop that renders a percentage scale with tick marks (minor at 5% intervals, major at 0/25/50/75/100%) below the progress bar. This moves functionality from Todoist's wrapper component into reactist itself.
1 parent c06b0bc commit 1451b85

4 files changed

Lines changed: 185 additions & 4 deletions

File tree

src/components/progress-bar/progress-bar.module.css

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
--reactist-progressbar-radius-inner: var(--reactist-progressbar-height);
55
--reactist-progressbar-track: var(--reactist-framework-fill-crest);
66
--reactist-progressbar-fill: var(--reactist-bg-brand);
7+
--reactist-progressbar-scale-tint: var(--reactist-content-tertiary);
8+
--reactist-progressbar-scale-tick-height: 8px;
9+
--reactist-progressbar-scale-minor-tick-opacity: 0.15;
10+
--reactist-progressbar-scale-major-tick-opacity: 0.35;
711
}
812

913
.progressBar {
@@ -18,3 +22,81 @@
1822
border-radius: var(--reactist-progressbar-radius-inner);
1923
background-color: var(--reactist-progressbar-fill);
2024
}
25+
26+
.wrapper {
27+
width: 100%;
28+
}
29+
30+
.scaleContainer {
31+
padding-top: var(--reactist-spacing-xsmall);
32+
}
33+
34+
.ticks {
35+
position: relative;
36+
display: block;
37+
/* Reserve space for tick marks above the labels: tick height + label line height */
38+
padding-top: calc(var(--reactist-progressbar-scale-tick-height) + 16px);
39+
color: var(--reactist-progressbar-scale-tint);
40+
font-size: var(--reactist-font-size-caption);
41+
}
42+
43+
.ticks > span {
44+
position: absolute;
45+
bottom: 0;
46+
text-align: left;
47+
/* Nudge labels 1px right to align with the 1px-wide tick marks */
48+
padding-left: 1px;
49+
}
50+
51+
.ticks > span:nth-child(1) {
52+
left: 0%;
53+
}
54+
55+
.ticks > span:nth-child(2) {
56+
left: 25%;
57+
}
58+
59+
.ticks > span:nth-child(3) {
60+
left: 50%;
61+
}
62+
63+
.ticks > span:nth-child(4) {
64+
left: 75%;
65+
}
66+
67+
.ticks > span:nth-child(5) {
68+
left: 100%;
69+
}
70+
71+
.ticks::before {
72+
/* minor tick marks every 5% */
73+
content: '';
74+
position: absolute;
75+
top: 0;
76+
left: 0;
77+
right: 0;
78+
height: var(--reactist-progressbar-scale-tick-height);
79+
background-image: repeating-linear-gradient(to right, currentColor 0 1px, transparent 1px 5%);
80+
opacity: var(--reactist-progressbar-scale-minor-tick-opacity);
81+
}
82+
83+
.ticks::after {
84+
/* major tick marks at 0%, 25%, 50%, 75% and an explicit 100% end mark */
85+
content: '';
86+
position: absolute;
87+
top: 0;
88+
left: 0;
89+
right: 0;
90+
height: var(--reactist-progressbar-scale-tick-height);
91+
background-image:
92+
linear-gradient(to right, currentColor 0 1px, transparent 1px),
93+
linear-gradient(currentColor, currentColor);
94+
background-size:
95+
25% 100%,
96+
1px 100%;
97+
background-repeat: repeat-x, no-repeat;
98+
background-position:
99+
left top,
100+
right top;
101+
opacity: var(--reactist-progressbar-scale-major-tick-opacity);
102+
}

src/components/progress-bar/progress-bar.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,45 @@ describe('ProgressBar', () => {
2121
expect(screen.getByRole('progressbar')).toHaveValue(100)
2222
})
2323

24+
describe('showScale', () => {
25+
it('does not render scale by default', () => {
26+
const { container } = render(<ProgressBar fillPercentage={50} />)
27+
expect(container.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
28+
})
29+
30+
it('renders scale with correct labels when showScale is true', () => {
31+
const { container } = render(<ProgressBar fillPercentage={50} showScale />)
32+
const ticks = container.querySelector('[aria-hidden="true"]')
33+
expect(ticks).toBeInTheDocument()
34+
35+
const spans = ticks!.querySelectorAll('span')
36+
expect(spans).toHaveLength(5)
37+
expect(spans[0]).toHaveTextContent('00')
38+
expect(spans[1]).toHaveTextContent('25')
39+
expect(spans[2]).toHaveTextContent('50')
40+
expect(spans[3]).toHaveTextContent('75')
41+
expect(spans[4]).toHaveTextContent('')
42+
})
43+
44+
it('marks scale as aria-hidden', () => {
45+
const { container } = render(<ProgressBar fillPercentage={50} showScale />)
46+
const ticks = container.querySelector('[aria-hidden="true"]')
47+
expect(ticks).toBeInTheDocument()
48+
})
49+
50+
it('applies className to wrapper when showScale is true', () => {
51+
const { container } = render(
52+
<ProgressBar fillPercentage={50} showScale className="custom-class" />,
53+
)
54+
// The outermost div should have the custom class
55+
expect(container.firstElementChild).toHaveClass('custom-class')
56+
// The progress bar div should not have the custom class
57+
expect(container.querySelector('[class*="progressBar"]')).not.toHaveClass(
58+
'custom-class',
59+
)
60+
})
61+
})
62+
2463
describe('a11y', () => {
2564
it('renders with no a11y violations', async () => {
2665
const { container } = render(<ProgressBar fillPercentage={50} />)
@@ -36,5 +75,12 @@ describe('ProgressBar', () => {
3675
'Step 2: Copying files...',
3776
)
3877
})
78+
79+
it('renders with no a11y violations when showScale is true', async () => {
80+
const { container } = render(<ProgressBar fillPercentage={50} showScale />)
81+
const results = await axe(container)
82+
83+
expect(results).toHaveNoViolations()
84+
})
3985
})
4086
})

src/components/progress-bar/progress-bar.tsx

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,44 @@ type Props = {
1313
fillPercentage?: number
1414
/** Defines the human readable text alternative for assitive technologies. */
1515
'aria-valuetext'?: string
16+
/** When true, renders a percentage scale with tick marks below the progress bar. */
17+
showScale?: boolean
1618
}
17-
function ProgressBar({ fillPercentage = 0, className, 'aria-valuetext': ariaValuetext }: Props) {
18-
const finalClassName = classNames(styles.progressBar, className)
19+
function ProgressBar({
20+
fillPercentage = 0,
21+
className,
22+
'aria-valuetext': ariaValuetext,
23+
showScale,
24+
}: Props) {
1925
const width = fillPercentage < 0 ? 0 : fillPercentage > 100 ? 100 : fillPercentage
20-
return (
21-
<div className={finalClassName}>
26+
27+
const bar = (
28+
<div className={classNames(styles.progressBar, !showScale && className)}>
2229
<div className={styles.inner} style={{ width: `${width}%` }} />
2330
<HiddenVisually>
2431
<progress value={width} max={100} aria-valuetext={ariaValuetext ?? undefined} />
2532
</HiddenVisually>
2633
</div>
2734
)
35+
36+
if (!showScale) {
37+
return bar
38+
}
39+
40+
return (
41+
<div className={classNames(styles.wrapper, className)}>
42+
{bar}
43+
<div className={styles.scaleContainer}>
44+
<div className={styles.ticks} aria-hidden="true">
45+
<span>00</span>
46+
<span>25</span>
47+
<span>50</span>
48+
<span>75</span>
49+
<span />
50+
</div>
51+
</div>
52+
</div>
53+
)
2854
}
2955
ProgressBar.displayName = 'ProgressBar'
3056

stories/components/ProgressBar.stories.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,27 @@ export const ProgressBarStory = () => (
3737
</section>
3838
)
3939

40+
export const ProgressBarWithScaleStory = () => (
41+
<section className="story">
42+
<p>Progress Bars with Scale</p>
43+
<Box paddingY="small">
44+
<ProgressBar fillPercentage={0} showScale />
45+
</Box>
46+
<Box paddingY="small">
47+
<ProgressBar fillPercentage={25} showScale />
48+
</Box>
49+
<Box paddingY="small">
50+
<ProgressBar fillPercentage={50} showScale />
51+
</Box>
52+
<Box paddingY="small">
53+
<ProgressBar fillPercentage={75} showScale />
54+
</Box>
55+
<Box paddingY="small">
56+
<ProgressBar fillPercentage={100} showScale />
57+
</Box>
58+
</section>
59+
)
60+
4061
export const ProgressBarPlaygroundStory = (args) => (
4162
<section className="story">
4263
<ProgressBar {...args} />
@@ -45,6 +66,7 @@ export const ProgressBarPlaygroundStory = (args) => (
4566

4667
ProgressBarPlaygroundStory.args = {
4768
fillPercentage: 50,
69+
showScale: false,
4870
}
4971

5072
ProgressBarPlaygroundStory.argTypes = {
@@ -53,6 +75,11 @@ ProgressBarPlaygroundStory.argTypes = {
5375
type: 'number',
5476
},
5577
},
78+
showScale: {
79+
control: {
80+
type: 'boolean',
81+
},
82+
},
5683
className: {
5784
control: {
5885
type: null,

0 commit comments

Comments
 (0)