progressRing.tsx 4.1 KB

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