index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. import React, { CSSProperties } from 'react';
  2. import cls from 'classnames';
  3. import PropTypes from 'prop-types';
  4. import { cssClasses, strings } from '@douyinfe/semi-foundation/avatar/constants';
  5. import AvatarFoundation, { AvatarAdapter } from '@douyinfe/semi-foundation/avatar/foundation';
  6. import '@douyinfe/semi-foundation/avatar/avatar.scss';
  7. import { noop } from '@douyinfe/semi-foundation/utils/function';
  8. import BaseComponent from '../_base/baseComponent';
  9. import { AvatarProps } from './interface';
  10. import { handlePrevent } from '@douyinfe/semi-foundation/utils/a11y';
  11. import { getDefaultPropsFromGlobalConfig } from "../_utils";
  12. import TopSlotSvg from "./TopSlotSvg";
  13. const sizeSet = strings.SIZE;
  14. const shapeSet = strings.SHAPE;
  15. const colorSet = strings.COLOR;
  16. const prefixCls = cssClasses.PREFIX;
  17. export * from './interface';
  18. export interface AvatarState {
  19. isImgExist: boolean;
  20. hoverContent: React.ReactNode;
  21. focusVisible: boolean;
  22. scale: number
  23. }
  24. export default class Avatar extends BaseComponent<AvatarProps, AvatarState> {
  25. static __SemiComponentName__ = "Avatar";
  26. static defaultProps = getDefaultPropsFromGlobalConfig(Avatar.__SemiComponentName__, {
  27. size: 'medium',
  28. color: 'grey',
  29. shape: 'circle',
  30. gap: 3,
  31. onClick: noop,
  32. onMouseEnter: noop,
  33. onMouseLeave: noop,
  34. })
  35. static elementType: string;
  36. static propTypes = {
  37. children: PropTypes.node,
  38. color: PropTypes.oneOf(colorSet),
  39. shape: PropTypes.oneOf(shapeSet),
  40. size: PropTypes.oneOf(sizeSet),
  41. hoverMask: PropTypes.node,
  42. className: PropTypes.string,
  43. style: PropTypes.object,
  44. gap: PropTypes.number,
  45. imgAttr: PropTypes.object,
  46. src: PropTypes.string,
  47. srcSet: PropTypes.string,
  48. alt: PropTypes.string,
  49. onError: PropTypes.func,
  50. onClick: PropTypes.func,
  51. onMouseEnter: PropTypes.func,
  52. onMouseLeave: PropTypes.func,
  53. bottomSlot: PropTypes.shape({
  54. render: PropTypes.func,
  55. shape: PropTypes.oneOf(['circle', 'square']),
  56. text: PropTypes.node,
  57. bgColor: PropTypes.string,
  58. textColor: PropTypes.string,
  59. className: PropTypes.string,
  60. style: PropTypes.object,
  61. }),
  62. topSlot: PropTypes.shape({
  63. render: PropTypes.func,
  64. gradientStart: PropTypes.string,
  65. gradientEnd: PropTypes.string,
  66. text: PropTypes.node,
  67. textColor: PropTypes.string,
  68. className: PropTypes.string,
  69. style: PropTypes.object,
  70. }),
  71. border: PropTypes.oneOfType([
  72. PropTypes.shape({
  73. color: PropTypes.string,
  74. motion: PropTypes.bool,
  75. }),
  76. PropTypes.bool,
  77. ]),
  78. contentMotion: PropTypes.bool,
  79. };
  80. foundation!: AvatarFoundation;
  81. avatarRef: React.RefObject<HTMLElement | null>;
  82. constructor(props: AvatarProps) {
  83. super(props);
  84. this.state = {
  85. isImgExist: true,
  86. hoverContent: '',
  87. focusVisible: false,
  88. scale: 1,
  89. };
  90. this.onEnter = this.onEnter.bind(this);
  91. this.onLeave = this.onLeave.bind(this);
  92. this.handleError = this.handleError.bind(this);
  93. this.handleKeyDown = this.handleKeyDown.bind(this);
  94. this.getContent = this.getContent.bind(this);
  95. this.avatarRef = React.createRef();
  96. }
  97. get adapter(): AvatarAdapter<AvatarProps, AvatarState> {
  98. return {
  99. ...super.adapter,
  100. notifyImgState: (isImgExist: boolean) => {
  101. this.setState({ isImgExist });
  102. },
  103. notifyEnter: (e: React.MouseEvent) => {
  104. const { hoverMask } = this.props;
  105. const hoverContent = hoverMask;
  106. this.setState({ hoverContent }, () => {
  107. const { onMouseEnter } = this.props;
  108. onMouseEnter && onMouseEnter(e);
  109. });
  110. },
  111. notifyLeave: (e: React.MouseEvent) => {
  112. this.setState({ hoverContent: '' }, () => {
  113. const { onMouseLeave } = this.props;
  114. onMouseLeave && onMouseLeave(e);
  115. });
  116. },
  117. setFocusVisible: (focusVisible: boolean): void => {
  118. this.setState({ focusVisible });
  119. },
  120. setScale: (scale: number) => {
  121. this.setState({ scale });
  122. },
  123. getAvatarNode: () => {
  124. return this.avatarRef?.current;
  125. }
  126. };
  127. }
  128. componentDidMount() {
  129. this.foundation = new AvatarFoundation<AvatarProps, AvatarState>(this.adapter);
  130. this.foundation.init();
  131. }
  132. componentDidUpdate(prevProps: AvatarProps) {
  133. if (this.props.src && this.props.src !== prevProps.src) {
  134. const image = new Image(0, 0);
  135. image.src = this.props.src;
  136. image.onload = () => {
  137. this.setState({ isImgExist: true });
  138. };
  139. image.onerror = () => {
  140. this.setState({ isImgExist: false });
  141. };
  142. image.onabort = () => {
  143. this.setState({ isImgExist: false });
  144. };
  145. }
  146. if (typeof this.props.children === "string" && this.props.children !== prevProps.children) {
  147. this.foundation.changeScale();
  148. }
  149. }
  150. componentWillUnmount() {
  151. this.foundation.destroy();
  152. }
  153. onEnter(e: React.MouseEvent) {
  154. this.foundation.handleEnter(e);
  155. }
  156. onLeave(e: React.MouseEvent) {
  157. this.foundation.handleLeave(e);
  158. }
  159. handleError() {
  160. this.foundation.handleImgLoadError();
  161. }
  162. handleKeyDown(event: any) {
  163. const { onClick } = this.props;
  164. switch (event.key) {
  165. case "Enter":
  166. onClick(event);
  167. handlePrevent(event);
  168. break;
  169. case 'Escape':
  170. event.target.blur();
  171. break;
  172. default:
  173. break;
  174. }
  175. }
  176. handleFocusVisible = (event: React.FocusEvent) => {
  177. this.foundation.handleFocusVisible(event);
  178. }
  179. handleBlur = (event: React.FocusEvent) => {
  180. this.foundation.handleBlur();
  181. }
  182. getContent = () => {
  183. const { children, onClick, imgAttr, src, srcSet, alt } = this.props;
  184. const { isImgExist } = this.state;
  185. let content = children;
  186. const clickable = onClick !== noop;
  187. const isImg = src && isImgExist;
  188. const a11yFocusProps = {
  189. tabIndex: 0,
  190. onKeyDown: this.handleKeyDown,
  191. onFocus: this.handleFocusVisible,
  192. onBlur: this.handleBlur,
  193. };
  194. if (isImg) {
  195. const finalAlt = clickable ? `clickable Avatar: ${alt}` : alt;
  196. const imgBasicProps = {
  197. src,
  198. srcSet,
  199. onError: this.handleError,
  200. ...imgAttr,
  201. className: cls({
  202. [`${prefixCls}-no-focus-visible`]: clickable,
  203. }),
  204. };
  205. const imgProps = clickable ? { ...imgBasicProps, ...a11yFocusProps } : imgBasicProps;
  206. content = (
  207. <img alt={finalAlt} {...imgProps}/>
  208. );
  209. } else if (typeof children === 'string') {
  210. const tempAlt = alt ?? children;
  211. const finalAlt = clickable ? `clickable Avatar: ${tempAlt}` : tempAlt;
  212. const props = {
  213. role: 'img',
  214. 'aria-label': finalAlt,
  215. className: cls(`${prefixCls}-label`,
  216. {
  217. [`${prefixCls}-no-focus-visible`]: clickable,
  218. }
  219. ),
  220. };
  221. const finalProps = clickable ? { ...props, ...a11yFocusProps } : props;
  222. const stringStyle: React.CSSProperties = {
  223. transform: `scale(${this.state.scale})`,
  224. };
  225. content = (
  226. <span className={`${prefixCls}-content`} style={stringStyle}>
  227. <span {...finalProps} x-semi-prop="children">{children}</span>
  228. </span>
  229. );
  230. }
  231. return content;
  232. }
  233. renderBottomSlot = () => {
  234. if (!this.props.bottomSlot) {
  235. return null;
  236. }
  237. if (this.props.bottomSlot.render) {
  238. return this.props.bottomSlot.render();
  239. }
  240. const renderContent = this.props.bottomSlot.render ?? (() => {
  241. const style: CSSProperties = {};
  242. if (this.props.bottomSlot.bgColor) {
  243. style['backgroundColor'] = this.props.bottomSlot.bgColor;
  244. }
  245. if (this.props.bottomSlot.textColor) {
  246. style['color'] = this.props.bottomSlot.textColor;
  247. }
  248. return <span style={style}
  249. className={cls(`${prefixCls}-bottom_slot-shape_${this.props.bottomSlot.shape}`, `${prefixCls}-bottom_slot-shape_${this.props.bottomSlot.shape}-${this.props.size}`, this.props.bottomSlot.className ?? "")}>
  250. {this.props.bottomSlot.text}
  251. </span>;
  252. });
  253. return <div className={cls([`${prefixCls}-bottom_slot`])} style={this.props.bottomSlot.style ?? {}}>
  254. {renderContent()}
  255. </div>;
  256. }
  257. renderTopSlot = () => {
  258. if (!this.props.topSlot) {
  259. return null;
  260. }
  261. if (this.props.topSlot.render) {
  262. return this.props.topSlot.render();
  263. }
  264. const textStyle: CSSProperties = {};
  265. if (this.props.topSlot.textColor) {
  266. textStyle['color'] = this.props.topSlot.textColor;
  267. }
  268. return <div style={this.props.topSlot.style ?? {}}
  269. className={cls([`${prefixCls}-top_slot-wrapper`, this.props.topSlot.className ?? "", {
  270. [`${prefixCls}-animated`]: this.props.contentMotion,
  271. }])}>
  272. <div className={cls([`${prefixCls}-top_slot-bg`, `${prefixCls}-top_slot-bg-${this.props.size}`])}>
  273. <div
  274. className={cls([`${prefixCls}-top_slot-bg-svg`, `${prefixCls}-top_slot-bg-svg-${this.props.size}`])}>
  275. <TopSlotSvg gradientStart={this.props.topSlot.gradientStart ?? "var(--semi-color-primary)"}
  276. gradientEnd={this.props.topSlot.gradientEnd ?? "var(--semi-color-primary)"}/>
  277. </div>
  278. </div>
  279. <div className={cls([`${prefixCls}-top_slot`])}>
  280. <div
  281. style={textStyle}
  282. className={cls([`${prefixCls}-top_slot-content`, `${prefixCls}-top_slot-content-${this.props.size}`])}>{this.props.topSlot.text}</div>
  283. </div>
  284. </div>;
  285. }
  286. render() {
  287. const {
  288. shape,
  289. children,
  290. size,
  291. color,
  292. className,
  293. hoverMask,
  294. onClick,
  295. imgAttr,
  296. src,
  297. srcSet,
  298. style,
  299. alt,
  300. gap,
  301. bottomSlot,
  302. topSlot,
  303. border,
  304. contentMotion,
  305. ...others
  306. } = this.props;
  307. const { isImgExist, hoverContent, focusVisible } = this.state;
  308. let customStyle: CSSProperties = {};
  309. if (!strings.SIZE.includes(size)) {
  310. customStyle = { width: size, height: size };
  311. }
  312. customStyle = { ...customStyle, ...style };
  313. const shouldWrap = bottomSlot || topSlot || border;
  314. const mouseEvent = {
  315. onClick: onClick,
  316. onMouseEnter: this.onEnter,
  317. onMouseLeave: this.onLeave,
  318. };
  319. const isImg = src && isImgExist;
  320. const avatarCls = cls(
  321. prefixCls,
  322. {
  323. [`${prefixCls}-${shape}`]: shape,
  324. [`${prefixCls}-${size}`]: size,
  325. [`${prefixCls}-${color}`]: color && !isImg,
  326. [`${prefixCls}-img`]: isImg,
  327. [`${prefixCls}-focus`]: focusVisible,
  328. [`${prefixCls}-animated`]: contentMotion,
  329. },
  330. className
  331. );
  332. const hoverRender = hoverContent ? (
  333. <div className={`${prefixCls}-hover`} x-semi-prop="hoverContent">{hoverContent}</div>) : null;
  334. let avatar = <span
  335. {...(others as any)}
  336. style={shouldWrap ? {} : customStyle}
  337. className={avatarCls}
  338. {...(shouldWrap ? {} : mouseEvent)}
  339. role='listitem'
  340. ref={this.avatarRef}>
  341. {this.getContent()}
  342. {hoverRender}
  343. </span>;
  344. if (border) {
  345. const borderStyle: CSSProperties = {};
  346. if (typeof border === 'object' && border?.color) {
  347. borderStyle['borderColor'] = border?.color;
  348. }
  349. avatar = <div style={{ position: "relative", ...customStyle }}>
  350. {avatar}
  351. <span style={borderStyle} className={cls([
  352. `${prefixCls}-additionalBorder`,
  353. `${prefixCls}-additionalBorder-${size}`,
  354. {
  355. [`${prefixCls}-${shape}`]: shape,
  356. },
  357. ])}>
  358. </span>
  359. {
  360. (typeof this.props.border === 'object' && this.props.border.motion) &&
  361. <span style={borderStyle} className={cls([
  362. `${prefixCls}-additionalBorder`,
  363. `${prefixCls}-additionalBorder-${size}`,
  364. {
  365. [`${prefixCls}-${shape}`]: shape,
  366. [`${prefixCls}-additionalBorder-animated`]: typeof this.props.border === 'object' && this.props.border?.motion,
  367. },
  368. ])}/>
  369. }
  370. </div>;
  371. }
  372. if (shouldWrap) {
  373. return <span className={cls([`${prefixCls}-wrapper`])} style={customStyle} {...mouseEvent}>
  374. {avatar}
  375. {topSlot && ["extra-small", "small", "default", "medium", "large", "extra-large"].includes(size) && shape === "circle" && this.renderTopSlot()}
  376. {bottomSlot && ["extra-small", "small", "default", "medium", "large", "extra-large"].includes(size) && this.renderBottomSlot()}
  377. </span>;
  378. } else {
  379. return avatar;
  380. }
  381. }
  382. }
  383. Avatar.elementType = 'Avatar';