Skip to content

Commit

Permalink
feat: add Circular Progress (#2122)
Browse files Browse the repository at this point in the history
  • Loading branch information
snitin315 committed Apr 18, 2024
1 parent 2aec1b4 commit 410cfb5
Show file tree
Hide file tree
Showing 19 changed files with 7,609 additions and 1,414 deletions.
41 changes: 41 additions & 0 deletions .changeset/six-planes-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@razorpay/blade": minor
---

feat: add `circular` variant for the `ProgressBar` component

#### Changes

- The `"meter"` & `"progress"` values for the `variant` prop are deprecated in favor of the new `type?: "meter" | "progress"` prop.
- The `variant` prop now accepts `"linear"` & `"circular"` values.
- **Usage:**

```js
<ProgressBar variant="circular" value={20}> label="Label" />
```

#### Migration with Codemod

- 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:

> Need help? Check out [jscodeshift docs](https://github.com/facebook/jscodeshift) for CLI usage tips.
```sh
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/**"
```

- There might be some situations where the codemod falls short, If you encounter errors, refer the following examples to migrate the component manually:

```diff
- <ProgressBar value={20}> label="Label" />
+ <ProgressBar type="progress" value={20}> label="Label" />

- <ProgressBar variant="progress" value={20}> label="Label" />
+ <ProgressBar type="progress" variant="linear" value={20}> label="Label" />

- <ProgressBar variant="meter" value={20}> label="Label" />
+ <ProgressBar type="meter" variant="linear" value={20}> label="Label" />
```



Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { applyTransform } from '@hypermod/utils';
import * as transformer from '..';

it('should migrate the ProgressBar component', async () => {
const result = await applyTransform(
transformer,
`
const App = () => (
<>
<ProgressBar value={20} label="Label" />
<ProgressBar variant="meter" value={20} label="Label" />
<ProgressBar variant="progress" value={20} label="Label" />
</>
);
`,
{ parser: 'tsx' },
);

expect(result).toMatchInlineSnapshot(`
"const App = () => (
<>
<ProgressBar value={20} label="Label" type="progress" />
<ProgressBar type="meter" value={20} label="Label" />
<ProgressBar type="progress" value={20} label="Label" />
</>
);"
`);
});
89 changes: 89 additions & 0 deletions packages/blade/codemods/migrate-progressbar/transformers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { Transform } from 'jscodeshift';

import { red, isExpression } from '../../brand-refresh/transformers/utils';

const transformer: Transform = (file, api, options) => {
// Don't transform if the file doesn't import `@razorapy/blade/components` because it's not using Blade components
// Allow the migration test file to be transformed
if (!file.source.includes('@razorpay/blade/components') && file.path !== undefined) {
return file.source;
}

const j = api.jscodeshift;
const root = j.withParser('tsx')(file.source);

// Add type prop if variant prop is not defined
try {
root
.find(j.JSXElement, {
openingElement: {
name: {
name: 'ProgressBar',
},
},
})
.replaceWith(({ node }) => {
const variantAttribute = node.openingElement.attributes.find(
(attribute) => attribute.name?.name === 'variant',
);

if (!variantAttribute) {
node.openingElement.attributes?.push(
j.jsxAttribute(j.jsxIdentifier('type'), j.literal('progress')),
);
}

return node;
});
} catch (error) {
console.error(
red(
`⛔️ ${file.path}: Oops! Ran into an issue while adding the "type" prop to the ProgressBar component.`,
),
`\n${red(error.stack)}\n`,
);
}

// Update the variant prop to type prop if defined
try {
root
.find(j.JSXElement, {
openingElement: {
name: {
name: 'ProgressBar',
},
},
})
.find(j.JSXAttribute, {
name: {
name: 'variant',
},
})
.replaceWith(({ node }) => {
if (isExpression(node)) {
console.warn(
red('\n⛔️ Expression found in the "variant" attribute, please update manually:'),
red(`${file.path}:${node.loc?.start.line}:${node.loc.start.column}\n`),
);
return node;
}

if (node.value?.value === 'progress' || node.value?.value === 'meter') {
node.name.name = 'type';
}

return node;
});
} catch (error) {
console.error(
red(
`⛔️ ${file.path}: Oops! Ran into an issue while updating the "variant" prop of the ProgressBar component.`,
),
`\n${red(error.stack)}\n`,
);
}

return root.toSource(options.printOptions);
};

export default transformer;
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import React, { useEffect } from 'react';
import styled from 'styled-components/native';
import Animated, {
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withSequence,
withTiming,
} from 'react-native-reanimated';
import { Text as SVGText, Circle } from 'react-native-svg';
import type { CircularProgressBarFilledProps } from './types';
import { circularProgressSizeTokens, getCircularProgressSVGTokens } from './progressBarTokens';
import { CircularProgressLabel } from './CircularProgressLabel';
import getIn from '~utils/lodashButBetter/get';
import BaseBox from '~components/Box/BaseBox';
import { makeMotionTime } from '~utils/makeMotionTime';
import type { TextProps } from '~components/Typography';
import { getTextProps } from '~components/Typography';
import { useTheme } from '~components/BladeProvider';
import { castNativeType } from '~utils';
import { Svg } from '~components/Icons/_Svg';
import getBaseTextStyles from '~components/Typography/BaseText/getBaseTextStyles';

const pulseAnimation = {
opacityInitial: 1,
opacityMid: 0.65,
opacityFinal: 1,
};

const StyledSVGText = styled(SVGText)<Pick<TextProps<{ variant: 'body' }>, 'size' | 'weight'>>(
({ theme, size, weight }) => {
const textProps = getTextProps({ variant: 'body', size, weight });

return {
...getBaseTextStyles({ theme, ...textProps }),
strokeWidth: 0,
fill: getIn(theme.colors, textProps.color!),
};
},
);

const CircularProgressBarFilled = ({
progressPercent,
fillColor,
backgroundColor,
size = 'small',
label,
showPercentage = true,
isMeter,
motionEasing,
pulseMotionDuration,
pulseMotionDelay,
fillMotionDuration,
}: CircularProgressBarFilledProps): React.ReactElement => {
const {
sqSize,
strokeWidth,
radius,
viewBox,
dashArray,
dashOffset,
} = getCircularProgressSVGTokens({ size, progressPercent });

const AnimatedCircle = Animated.createAnimatedComponent(Circle);
const animatedOpacity = useSharedValue(pulseAnimation.opacityInitial);
const animatedStrokeDashoffset = useSharedValue(dashOffset);
const { theme } = useTheme();
const fillAndPulseEasing = getIn(theme.motion, motionEasing);
const pulseDuration =
castNativeType(makeMotionTime(getIn(theme.motion, pulseMotionDuration))) / 2;

// Trigger animation for progress fill
useEffect(() => {
const fillDuration = castNativeType(makeMotionTime(getIn(theme.motion, fillMotionDuration)));
animatedStrokeDashoffset.value = withTiming(dashOffset, {
duration: fillDuration,
easing: fillAndPulseEasing,
});
return () => {
cancelAnimation(animatedStrokeDashoffset);
};
}, [dashOffset, animatedStrokeDashoffset, fillMotionDuration, theme, fillAndPulseEasing]);

// Trigger pulsating animation
useEffect(() => {
const pulsatingAnimationTimingConfig = {
duration: pulseDuration,
easing: fillAndPulseEasing,
};
if (!isMeter) {
animatedOpacity.value = withDelay(
castNativeType(makeMotionTime(getIn(theme.motion, pulseMotionDelay))),
withRepeat(
withSequence(
withTiming(pulseAnimation.opacityMid, pulsatingAnimationTimingConfig),
withTiming(pulseAnimation.opacityFinal, pulsatingAnimationTimingConfig),
),
-1,
),
);
}

return () => {
cancelAnimation(animatedOpacity);
};
}, [animatedOpacity, fillAndPulseEasing, pulseDuration, pulseMotionDelay, theme, isMeter]);

const firstIndicatorStyles = useAnimatedStyle(() => {
return {
strokeDashoffset: animatedStrokeDashoffset.value,
opacity: progressPercent < 100 ? animatedOpacity.value : 1,
};
});

return (
<BaseBox display="flex" width="fit-content" alignItems="center">
<Svg width={String(sqSize)} height={String(sqSize)} viewBox={viewBox}>
<Circle
fill="none"
stroke={backgroundColor}
cx={String(sqSize / 2)}
cy={String(sqSize / 2)}
r={String(radius)}
strokeWidth={`${strokeWidth}px`}
/>

<AnimatedCircle
fill="none"
stroke={fillColor}
cx={sqSize / 2}
cy={sqSize / 2}
r={radius}
strokeWidth={`${strokeWidth}px`}
// Start progress marker at 12 O'Clock
transform={`rotate(-90 ${sqSize / 2} ${sqSize / 2})`}
strokeDasharray={dashArray}
strokeDashoffset={dashOffset}
style={firstIndicatorStyles}
/>

{showPercentage && size !== 'small' && (
<StyledSVGText
size={circularProgressSizeTokens[size].percentTextSize}
weight="semibold"
x="50%"
y="50%"
textAnchor="middle"
dy=".5em"
>
{`${progressPercent}%`}
</StyledSVGText>
)}
</Svg>

<CircularProgressLabel
progressPercent={progressPercent}
size={size}
label={label}
showPercentage={showPercentage}
/>
</BaseBox>
);
};

export { CircularProgressBarFilled };

0 comments on commit 410cfb5

Please sign in to comment.