1
0

index.tsx 11 KB

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