index.tsx 12 KB

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