Skip to content

Commit 410cfb5

Browse files
authored
feat: add Circular Progress (#2122)
1 parent 2aec1b4 commit 410cfb5

19 files changed

+7609
-1414
lines changed

.changeset/six-planes-wash.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@razorpay/blade": minor
3+
---
4+
5+
feat: add `circular` variant for the `ProgressBar` component
6+
7+
#### Changes
8+
9+
- The `"meter"` & `"progress"` values for the `variant` prop are deprecated in favor of the new `type?: "meter" | "progress"` prop.
10+
- The `variant` prop now accepts `"linear"` & `"circular"` values.
11+
- **Usage:**
12+
13+
```js
14+
<ProgressBar variant="circular" value={20}> label="Label" />
15+
```
16+
17+
#### Migration with Codemod
18+
19+
- The codemod will automatically update the `ProgressBar` component. Execute the codemod on the file/directory that needs to be migrated for the page via the following command:
20+
21+
> Need help? Check out [jscodeshift docs](https://github.com/facebook/jscodeshift) for CLI usage tips.
22+
23+
```sh
24+
npx jscodeshift ./PATH_TO_YOUR_DIR --extensions=tsx,ts,jsx,js -t ./node_modules/@razorpay/blade/codemods/migrate-progressbar/transformers/index.ts --ignore-pattern="**/node_modules/**"
25+
```
26+
27+
- There might be some situations where the codemod falls short, If you encounter errors, refer the following examples to migrate the component manually:
28+
29+
```diff
30+
- <ProgressBar value={20}> label="Label" />
31+
+ <ProgressBar type="progress" value={20}> label="Label" />
32+
33+
- <ProgressBar variant="progress" value={20}> label="Label" />
34+
+ <ProgressBar type="progress" variant="linear" value={20}> label="Label" />
35+
36+
- <ProgressBar variant="meter" value={20}> label="Label" />
37+
+ <ProgressBar type="meter" variant="linear" value={20}> label="Label" />
38+
```
39+
40+
41+
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { applyTransform } from '@hypermod/utils';
2+
import * as transformer from '..';
3+
4+
it('should migrate the ProgressBar component', async () => {
5+
const result = await applyTransform(
6+
transformer,
7+
`
8+
const App = () => (
9+
<>
10+
<ProgressBar value={20} label="Label" />
11+
<ProgressBar variant="meter" value={20} label="Label" />
12+
<ProgressBar variant="progress" value={20} label="Label" />
13+
</>
14+
);
15+
`,
16+
{ parser: 'tsx' },
17+
);
18+
19+
expect(result).toMatchInlineSnapshot(`
20+
"const App = () => (
21+
<>
22+
<ProgressBar value={20} label="Label" type="progress" />
23+
<ProgressBar type="meter" value={20} label="Label" />
24+
<ProgressBar type="progress" value={20} label="Label" />
25+
</>
26+
);"
27+
`);
28+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { Transform } from 'jscodeshift';
2+
3+
import { red, isExpression } from '../../brand-refresh/transformers/utils';
4+
5+
const transformer: Transform = (file, api, options) => {
6+
// Don't transform if the file doesn't import `@razorapy/blade/components` because it's not using Blade components
7+
// Allow the migration test file to be transformed
8+
if (!file.source.includes('@razorpay/blade/components') && file.path !== undefined) {
9+
return file.source;
10+
}
11+
12+
const j = api.jscodeshift;
13+
const root = j.withParser('tsx')(file.source);
14+
15+
// Add type prop if variant prop is not defined
16+
try {
17+
root
18+
.find(j.JSXElement, {
19+
openingElement: {
20+
name: {
21+
name: 'ProgressBar',
22+
},
23+
},
24+
})
25+
.replaceWith(({ node }) => {
26+
const variantAttribute = node.openingElement.attributes.find(
27+
(attribute) => attribute.name?.name === 'variant',
28+
);
29+
30+
if (!variantAttribute) {
31+
node.openingElement.attributes?.push(
32+
j.jsxAttribute(j.jsxIdentifier('type'), j.literal('progress')),
33+
);
34+
}
35+
36+
return node;
37+
});
38+
} catch (error) {
39+
console.error(
40+
red(
41+
`⛔️ ${file.path}: Oops! Ran into an issue while adding the "type" prop to the ProgressBar component.`,
42+
),
43+
`\n${red(error.stack)}\n`,
44+
);
45+
}
46+
47+
// Update the variant prop to type prop if defined
48+
try {
49+
root
50+
.find(j.JSXElement, {
51+
openingElement: {
52+
name: {
53+
name: 'ProgressBar',
54+
},
55+
},
56+
})
57+
.find(j.JSXAttribute, {
58+
name: {
59+
name: 'variant',
60+
},
61+
})
62+
.replaceWith(({ node }) => {
63+
if (isExpression(node)) {
64+
console.warn(
65+
red('\n⛔️ Expression found in the "variant" attribute, please update manually:'),
66+
red(`${file.path}:${node.loc?.start.line}:${node.loc.start.column}\n`),
67+
);
68+
return node;
69+
}
70+
71+
if (node.value?.value === 'progress' || node.value?.value === 'meter') {
72+
node.name.name = 'type';
73+
}
74+
75+
return node;
76+
});
77+
} catch (error) {
78+
console.error(
79+
red(
80+
`⛔️ ${file.path}: Oops! Ran into an issue while updating the "variant" prop of the ProgressBar component.`,
81+
),
82+
`\n${red(error.stack)}\n`,
83+
);
84+
}
85+
86+
return root.toSource(options.printOptions);
87+
};
88+
89+
export default transformer;
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import React, { useEffect } from 'react';
2+
import styled from 'styled-components/native';
3+
import Animated, {
4+
cancelAnimation,
5+
useAnimatedStyle,
6+
useSharedValue,
7+
withDelay,
8+
withRepeat,
9+
withSequence,
10+
withTiming,
11+
} from 'react-native-reanimated';
12+
import { Text as SVGText, Circle } from 'react-native-svg';
13+
import type { CircularProgressBarFilledProps } from './types';
14+
import { circularProgressSizeTokens, getCircularProgressSVGTokens } from './progressBarTokens';
15+
import { CircularProgressLabel } from './CircularProgressLabel';
16+
import getIn from '~utils/lodashButBetter/get';
17+
import BaseBox from '~components/Box/BaseBox';
18+
import { makeMotionTime } from '~utils/makeMotionTime';
19+
import type { TextProps } from '~components/Typography';
20+
import { getTextProps } from '~components/Typography';
21+
import { useTheme } from '~components/BladeProvider';
22+
import { castNativeType } from '~utils';
23+
import { Svg } from '~components/Icons/_Svg';
24+
import getBaseTextStyles from '~components/Typography/BaseText/getBaseTextStyles';
25+
26+
const pulseAnimation = {
27+
opacityInitial: 1,
28+
opacityMid: 0.65,
29+
opacityFinal: 1,
30+
};
31+
32+
const StyledSVGText = styled(SVGText)<Pick<TextProps<{ variant: 'body' }>, 'size' | 'weight'>>(
33+
({ theme, size, weight }) => {
34+
const textProps = getTextProps({ variant: 'body', size, weight });
35+
36+
return {
37+
...getBaseTextStyles({ theme, ...textProps }),
38+
strokeWidth: 0,
39+
fill: getIn(theme.colors, textProps.color!),
40+
};
41+
},
42+
);
43+
44+
const CircularProgressBarFilled = ({
45+
progressPercent,
46+
fillColor,
47+
backgroundColor,
48+
size = 'small',
49+
label,
50+
showPercentage = true,
51+
isMeter,
52+
motionEasing,
53+
pulseMotionDuration,
54+
pulseMotionDelay,
55+
fillMotionDuration,
56+
}: CircularProgressBarFilledProps): React.ReactElement => {
57+
const {
58+
sqSize,
59+
strokeWidth,
60+
radius,
61+
viewBox,
62+
dashArray,
63+
dashOffset,
64+
} = getCircularProgressSVGTokens({ size, progressPercent });
65+
66+
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
67+
const animatedOpacity = useSharedValue(pulseAnimation.opacityInitial);
68+
const animatedStrokeDashoffset = useSharedValue(dashOffset);
69+
const { theme } = useTheme();
70+
const fillAndPulseEasing = getIn(theme.motion, motionEasing);
71+
const pulseDuration =
72+
castNativeType(makeMotionTime(getIn(theme.motion, pulseMotionDuration))) / 2;
73+
74+
// Trigger animation for progress fill
75+
useEffect(() => {
76+
const fillDuration = castNativeType(makeMotionTime(getIn(theme.motion, fillMotionDuration)));
77+
animatedStrokeDashoffset.value = withTiming(dashOffset, {
78+
duration: fillDuration,
79+
easing: fillAndPulseEasing,
80+
});
81+
return () => {
82+
cancelAnimation(animatedStrokeDashoffset);
83+
};
84+
}, [dashOffset, animatedStrokeDashoffset, fillMotionDuration, theme, fillAndPulseEasing]);
85+
86+
// Trigger pulsating animation
87+
useEffect(() => {
88+
const pulsatingAnimationTimingConfig = {
89+
duration: pulseDuration,
90+
easing: fillAndPulseEasing,
91+
};
92+
if (!isMeter) {
93+
animatedOpacity.value = withDelay(
94+
castNativeType(makeMotionTime(getIn(theme.motion, pulseMotionDelay))),
95+
withRepeat(
96+
withSequence(
97+
withTiming(pulseAnimation.opacityMid, pulsatingAnimationTimingConfig),
98+
withTiming(pulseAnimation.opacityFinal, pulsatingAnimationTimingConfig),
99+
),
100+
-1,
101+
),
102+
);
103+
}
104+
105+
return () => {
106+
cancelAnimation(animatedOpacity);
107+
};
108+
}, [animatedOpacity, fillAndPulseEasing, pulseDuration, pulseMotionDelay, theme, isMeter]);
109+
110+
const firstIndicatorStyles = useAnimatedStyle(() => {
111+
return {
112+
strokeDashoffset: animatedStrokeDashoffset.value,
113+
opacity: progressPercent < 100 ? animatedOpacity.value : 1,
114+
};
115+
});
116+
117+
return (
118+
<BaseBox display="flex" width="fit-content" alignItems="center">
119+
<Svg width={String(sqSize)} height={String(sqSize)} viewBox={viewBox}>
120+
<Circle
121+
fill="none"
122+
stroke={backgroundColor}
123+
cx={String(sqSize / 2)}
124+
cy={String(sqSize / 2)}
125+
r={String(radius)}
126+
strokeWidth={`${strokeWidth}px`}
127+
/>
128+
129+
<AnimatedCircle
130+
fill="none"
131+
stroke={fillColor}
132+
cx={sqSize / 2}
133+
cy={sqSize / 2}
134+
r={radius}
135+
strokeWidth={`${strokeWidth}px`}
136+
// Start progress marker at 12 O'Clock
137+
transform={`rotate(-90 ${sqSize / 2} ${sqSize / 2})`}
138+
strokeDasharray={dashArray}
139+
strokeDashoffset={dashOffset}
140+
style={firstIndicatorStyles}
141+
/>
142+
143+
{showPercentage && size !== 'small' && (
144+
<StyledSVGText
145+
size={circularProgressSizeTokens[size].percentTextSize}
146+
weight="semibold"
147+
x="50%"
148+
y="50%"
149+
textAnchor="middle"
150+
dy=".5em"
151+
>
152+
{`${progressPercent}%`}
153+
</StyledSVGText>
154+
)}
155+
</Svg>
156+
157+
<CircularProgressLabel
158+
progressPercent={progressPercent}
159+
size={size}
160+
label={label}
161+
showPercentage={showPercentage}
162+
/>
163+
</BaseBox>
164+
);
165+
};
166+
167+
export { CircularProgressBarFilled };

0 commit comments

Comments
 (0)