progressRing.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. import React from 'react';
  2. import {SerializedStyles} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {AnimatePresence, motion} from 'framer-motion';
  5. import testableTransition from 'app/utils/testableTransition';
  6. import theme, {Theme} from 'app/utils/theme';
  7. type TextProps = {
  8. textCss?: Props['textCss'];
  9. percent: number;
  10. theme: Theme;
  11. };
  12. type Props = React.HTMLAttributes<SVGSVGElement> & {
  13. value: number;
  14. maxValue?: number;
  15. minValue?: number;
  16. size?: number;
  17. /**
  18. * The width of the progress ring bar
  19. */
  20. barWidth?: number;
  21. /**
  22. * Text to display in the center of the ring
  23. */
  24. text?: React.ReactNode;
  25. /**
  26. * The css to apply to the center text. A function may be provided to compute
  27. * styles based on the state of the progress bar.
  28. */
  29. textCss?: (p: TextProps) => SerializedStyles;
  30. /**
  31. * Apply a micro animation when the text value changes
  32. */
  33. animateText?: boolean;
  34. /**
  35. * The color of the ring bar. A function may be provided to compute the color
  36. * based on the percent value filled of the progress bar.
  37. */
  38. progressColor?: string;
  39. /**
  40. * The color of the ring background
  41. */
  42. backgroundColor?: string;
  43. /**
  44. * Endcaps on the progress bar
  45. */
  46. progressEndcaps?: React.SVGAttributes<SVGCircleElement>['strokeLinecap'];
  47. };
  48. const Text = styled('div')<Omit<TextProps, 'theme'>>`
  49. position: absolute;
  50. display: flex;
  51. align-items: center;
  52. justify-content: center;
  53. height: 100%;
  54. width: 100%;
  55. color: ${p => p.theme.chartLabel};
  56. font-size: ${p => p.theme.fontSizeExtraSmall};
  57. padding-top: 1px;
  58. transition: color 100ms;
  59. ${p => p.textCss && p.textCss(p)}
  60. `;
  61. const AnimatedText = motion(Text);
  62. AnimatedText.defaultProps = {
  63. initial: {opacity: 0, y: -10},
  64. animate: {opacity: 1, y: 0},
  65. exit: {opacity: 0, y: 10},
  66. transition: testableTransition(),
  67. };
  68. const ProgressRing = ({
  69. value,
  70. minValue = 0,
  71. maxValue = 100,
  72. size = 20,
  73. barWidth = 3,
  74. text,
  75. textCss,
  76. animateText = false,
  77. progressColor = theme.green300,
  78. backgroundColor = theme.gray200,
  79. progressEndcaps,
  80. ...p
  81. }: Props) => {
  82. const radius = size / 2 - barWidth / 2;
  83. const circumference = 2 * Math.PI * radius;
  84. const boundedValue = Math.min(Math.max(value, minValue), maxValue);
  85. const progress = (boundedValue - minValue) / (maxValue - minValue);
  86. const percent = progress * 100;
  87. const progressOffset = (1 - progress) * circumference;
  88. const TextComponent = animateText ? AnimatedText : Text;
  89. let textNode = (
  90. <TextComponent key={text?.toString()} {...{textCss, percent}}>
  91. {text}
  92. </TextComponent>
  93. );
  94. textNode = animateText ? (
  95. <AnimatePresence initial={false}>{textNode}</AnimatePresence>
  96. ) : (
  97. textNode
  98. );
  99. return (
  100. <RingSvg height={radius * 2 + barWidth} width={radius * 2 + barWidth} {...p}>
  101. <RingBackground
  102. r={radius}
  103. barWidth={barWidth}
  104. cx={radius + barWidth / 2}
  105. cy={radius + barWidth / 2}
  106. color={backgroundColor}
  107. />
  108. <RingBar
  109. strokeDashoffset={progressOffset}
  110. strokeLinecap={progressEndcaps}
  111. circumference={circumference}
  112. r={radius}
  113. barWidth={barWidth}
  114. cx={radius + barWidth / 2}
  115. cy={radius + barWidth / 2}
  116. color={progressColor}
  117. />
  118. <foreignObject height="100%" width="100%">
  119. {text !== undefined && textNode}
  120. </foreignObject>
  121. </RingSvg>
  122. );
  123. };
  124. const RingSvg = styled('svg')`
  125. position: relative;
  126. `;
  127. const RingBackground = styled('circle')<{color: string; barWidth: number}>`
  128. fill: none;
  129. stroke: ${p => p.color};
  130. stroke-width: ${p => p.barWidth}px;
  131. transition: stroke 100ms;
  132. `;
  133. const RingBar = styled('circle')<{
  134. color: string;
  135. circumference: number;
  136. barWidth: number;
  137. }>`
  138. fill: none;
  139. stroke: ${p => p.color};
  140. stroke-width: ${p => p.barWidth}px;
  141. stroke-dasharray: ${p => p.circumference} ${p => p.circumference};
  142. transform: rotate(-90deg);
  143. transform-origin: 50% 50%;
  144. transition: stroke-dashoffset 200ms, stroke 100ms;
  145. `;
  146. export default ProgressRing;
  147. // We export components to allow for css selectors
  148. export {RingBackground, RingBar, Text as RingText};