index.tsx 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import React, { ReactNode, Component } from 'react';
  2. import cls from 'classnames';
  3. import PropTypes from 'prop-types';
  4. import { cssClasses, strings } from '@douyinfe/semi-foundation/progress/constants';
  5. import '@douyinfe/semi-foundation/progress/progress.scss';
  6. import { Animation } from '@douyinfe/semi-animation';
  7. import { Motion } from '../_base/base';
  8. const prefixCls = cssClasses.PREFIX;
  9. export interface ProgressProps {
  10. 'aria-label'?: string | undefined;
  11. 'aria-labelledby'?: string | undefined;
  12. 'aria-valuetext'?: string | undefined;
  13. className?: string;
  14. direction?: 'horizontal' | 'vertical';
  15. format?: (percent: number) => React.ReactNode;
  16. id?: string;
  17. motion?: Motion;
  18. orbitStroke?: string;
  19. percent?: number;
  20. showInfo?: boolean;
  21. size?: 'default' | 'small' | 'large';
  22. stroke?: string;
  23. strokeLinecap?: 'round' | 'square';
  24. strokeWidth?: number;
  25. style?: React.CSSProperties;
  26. type?: 'line' | 'circle';
  27. width?: number;
  28. }
  29. export interface ProgressState {
  30. percentNumber: number;
  31. }
  32. class Progress extends Component<ProgressProps, ProgressState> {
  33. static propTypes = {
  34. 'aria-label': PropTypes.string,
  35. 'aria-labelledby': PropTypes.string,
  36. 'aria-valuetext': PropTypes.string,
  37. className: PropTypes.string,
  38. direction: PropTypes.oneOf(strings.directions),
  39. format: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
  40. id: PropTypes.string,
  41. motion: PropTypes.oneOfType([PropTypes.bool, PropTypes.func, PropTypes.object]),
  42. orbitStroke: PropTypes.string,
  43. percent: PropTypes.number,
  44. scale: PropTypes.number,
  45. showInfo: PropTypes.bool,
  46. size: PropTypes.oneOf(strings.sizes),
  47. stroke: PropTypes.string,
  48. strokeLinecap: PropTypes.oneOf(strings.strokeLineCap),
  49. strokeWidth: PropTypes.number,
  50. style: PropTypes.object,
  51. type: PropTypes.oneOf(strings.types),
  52. width: PropTypes.number,
  53. };
  54. static defaultProps = {
  55. className: '',
  56. direction: strings.DEFAULT_DIRECTION,
  57. format: (text: string): string => `${text}%`,
  58. motion: true,
  59. orbitStroke: 'var(--semi-color-fill-0)',
  60. percent: 0,
  61. showInfo: false,
  62. size: strings.DEFAULT_SIZE,
  63. stroke: 'var(--semi-color-success)',
  64. strokeLinecap: strings.DEFAULT_LINECAP,
  65. strokeWidth: 4,
  66. style: {},
  67. type: strings.DEFAULT_TYPE,
  68. };
  69. _mounted: boolean = true;
  70. animation: Animation;
  71. constructor(props: ProgressProps) {
  72. super(props);
  73. this._mounted = true;
  74. this.state = {
  75. percentNumber: this.props.percent // Specially used for animation of numbers
  76. };
  77. }
  78. componentDidUpdate(prevProps: ProgressProps): void {
  79. if (isNaN(this.props.percent) || isNaN(prevProps.percent)) {
  80. throw new Error('[Semi Progress]:percent can not be NaN');
  81. return;
  82. }
  83. if (prevProps.percent !== this.props.percent) {
  84. if (!this.props.motion) {
  85. // eslint-disable-next-line
  86. this.setState({ percentNumber: this.props.percent });
  87. return;
  88. }
  89. if (this.animation && this.animation.destroy) {
  90. this.animation.destroy();
  91. }
  92. this.animation = new Animation({
  93. from: { value: prevProps.percent },
  94. to: { value: this.props.percent }
  95. }, {
  96. // easing: 'cubic-bezier(0, .68, .3, 1)'
  97. easing: 'linear',
  98. duration: 300
  99. });
  100. this.animation.on('frame', (props: any) => {
  101. // prevent setState while component is unmounted but this timer is called
  102. if (this._mounted === false) {
  103. return;
  104. }
  105. // let percentNumber = Number.isInteger(props.value) ? props.value : Math.floor(props.value * 100) / 100;
  106. const percentNumber = parseInt(props.value);
  107. this.setState({ percentNumber });
  108. });
  109. this.animation.on('rest', () => {
  110. // prevent setState while component is unmounted but this timer is called
  111. if (this._mounted === false) {
  112. return;
  113. }
  114. this.setState({ percentNumber: this.props.percent });
  115. });
  116. this.animation.start();
  117. }
  118. }
  119. componentWillUnmount(): void {
  120. this.animation && this.animation.destroy();
  121. this._mounted = false;
  122. }
  123. renderCircleProgress(): ReactNode {
  124. const { strokeLinecap, style, className, strokeWidth, format, size, stroke, showInfo, percent, orbitStroke, id } = this.props;
  125. const ariaLabel = this.props['aria-label'];
  126. const ariaLabelledBy = this.props['aria-labelledby'];
  127. const ariaValueText = this.props['aria-valuetext'];
  128. const { percentNumber } = this.state;
  129. const classNames = {
  130. wrapper: cls(`${prefixCls}-circle`, className),
  131. svg: cls(`${prefixCls}-circle-ring`),
  132. circle: cls(`${prefixCls}-circle-ring-inner`)
  133. };
  134. const perc = this.calcPercent(percent);
  135. const percNumber = this.calcPercent(percentNumber);
  136. let width;
  137. if (this.props.width) {
  138. width = this.props.width;
  139. } else {
  140. size === strings.DEFAULT_SIZE ? width = 72 : width = 24;
  141. }
  142. // cx, cy is circle center
  143. const cy = width / 2;
  144. const cx = width / 2;
  145. const radius = (width - strokeWidth) / 2; // radius
  146. const circumference = radius * 2 * Math.PI;
  147. const strokeDashoffset = (1 - perc / 100) * circumference; // Offset
  148. const strokeDasharray = `${circumference} ${circumference}`;
  149. const text = format(percNumber);
  150. return (
  151. <div
  152. id={id}
  153. className={classNames.wrapper}
  154. style={style}
  155. role='progressbar'
  156. aria-valuemin={0}
  157. aria-valuemax={100}
  158. aria-valuenow={percNumber}
  159. aria-labelledby={ariaLabelledBy}
  160. aria-label={ariaLabel}
  161. aria-valuetext={ariaValueText}
  162. >
  163. <svg key={size} className={classNames.svg} height={width} width={width} aria-hidden>
  164. <circle
  165. strokeDashoffset={0}
  166. strokeWidth={strokeWidth}
  167. strokeDasharray={strokeDasharray}
  168. strokeLinecap={strokeLinecap}
  169. fill="transparent"
  170. stroke={orbitStroke}
  171. r={radius}
  172. cx={cx}
  173. cy={cy}
  174. aria-hidden
  175. />
  176. <circle
  177. className={classNames.circle}
  178. strokeDashoffset={strokeDashoffset}
  179. strokeWidth={strokeWidth}
  180. strokeDasharray={strokeDasharray}
  181. strokeLinecap={strokeLinecap}
  182. fill="transparent"
  183. stroke={stroke}
  184. r={radius}
  185. cx={cx}
  186. cy={cy}
  187. aria-hidden
  188. />
  189. </svg>
  190. {showInfo && size !== 'small' ? (<span className={`${prefixCls}-circle-text`}>{text}</span>) : null}
  191. </div>
  192. );
  193. }
  194. calcPercent(percent: number): number {
  195. let perc;
  196. if (percent > 100) {
  197. perc = 100;
  198. } else if (percent < 0) {
  199. perc = 0;
  200. } else {
  201. perc = percent;
  202. }
  203. return perc;
  204. }
  205. renderLineProgress(): ReactNode {
  206. const { className, style, stroke, direction, format, showInfo, size, percent, orbitStroke, id } = this.props;
  207. const ariaLabel = this.props['aria-label'];
  208. const ariaLabelledBy = this.props['aria-labelledby'];
  209. const ariaValueText = this.props['aria-valuetext'];
  210. const { percentNumber } = this.state;
  211. const progressWrapperCls = cls(prefixCls, className, {
  212. [`${prefixCls}-horizontal`]: direction === strings.DEFAULT_DIRECTION,
  213. [`${prefixCls}-vertical`]: direction !== strings.DEFAULT_DIRECTION,
  214. [`${prefixCls}-large`]: size === 'large',
  215. });
  216. const progressTrackCls = cls({
  217. [`${prefixCls}-track`]: true,
  218. });
  219. const innerCls = cls(`${prefixCls}-track-inner`);
  220. const perc = this.calcPercent(percent);
  221. const percNumber = this.calcPercent(percentNumber);
  222. const innerStyle: Record<string, any> = {
  223. backgroundColor: stroke
  224. };
  225. if (direction === strings.DEFAULT_DIRECTION) {
  226. innerStyle.width = `${perc}%`;
  227. } else {
  228. innerStyle.height = `${perc}%`;
  229. }
  230. const text = format(percNumber);
  231. return (
  232. <div
  233. id={id}
  234. className={progressWrapperCls}
  235. style={style}
  236. role='progressbar'
  237. aria-valuemin={0}
  238. aria-valuemax={100}
  239. aria-valuenow={perc}
  240. aria-labelledby={ariaLabelledBy}
  241. aria-label={ariaLabel}
  242. aria-valuetext={ariaValueText}
  243. >
  244. <div className={progressTrackCls} style={orbitStroke ? { backgroundColor: orbitStroke } : {}} aria-hidden>
  245. <div className={innerCls} style={innerStyle} aria-hidden />
  246. </div>
  247. {showInfo ? <div className={`${prefixCls}-line-text`}>{text}</div> : null}
  248. </div>
  249. );
  250. }
  251. render(): ReactNode {
  252. const { type } = this.props;
  253. if (type === 'line') {
  254. return this.renderLineProgress();
  255. } else {
  256. return this.renderCircleProgress();
  257. }
  258. }
  259. }
  260. export default Progress;