index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. import React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import BaseComponent from '../_base/baseComponent';
  4. import cls from 'classnames';
  5. import ConfigContext from '../configProvider/context';
  6. import { cssClasses, strings } from '@douyinfe/semi-foundation/rating/constants';
  7. import PropTypes from 'prop-types';
  8. import { noop } from '@douyinfe/semi-foundation/utils/function';
  9. import Item from './item';
  10. import Tooltip from '../tooltip';
  11. import RatingFoundation, { RatingAdapter } from '@douyinfe/semi-foundation/rating/foundation';
  12. import '@douyinfe/semi-foundation/rating/rating.scss';
  13. export type { RatingItemProps } from './item';
  14. export interface RatingProps {
  15. 'aria-describedby'?: string;
  16. 'aria-errormessage'?: string;
  17. 'aria-invalid'?: boolean;
  18. 'aria-label'?: string;
  19. 'aria-labelledby'?: string;
  20. 'aria-required'?: boolean;
  21. disabled?: boolean;
  22. value?: number;
  23. defaultValue?: number;
  24. count?: number;
  25. allowHalf?: boolean;
  26. allowClear?: boolean;
  27. style?: React.CSSProperties;
  28. prefixCls?: string;
  29. onChange?: (value: number) => void;
  30. onHoverChange?: (value: number) => void;
  31. className?: string;
  32. character?: React.ReactNode;
  33. tabIndex?: number;
  34. onFocus?: (e: React.FocusEvent) => void;
  35. onBlur?: (e: React.FocusEvent) => void;
  36. onKeyDown?: (e: React.KeyboardEvent) => void;
  37. onClick?: (e: React.MouseEvent | React.KeyboardEvent, index: number) => void;
  38. autoFocus?: boolean;
  39. size?: 'small' | 'default' | number;
  40. tooltips?: string[];
  41. id?: string;
  42. preventScroll?: boolean
  43. }
  44. export interface RatingState {
  45. value: number;
  46. hoverValue: number;
  47. focused: boolean;
  48. clearedValue: number;
  49. emptyStarFocusVisible: boolean
  50. }
  51. export default class Rating extends BaseComponent<RatingProps, RatingState> {
  52. static contextType = ConfigContext;
  53. static propTypes = {
  54. 'aria-describedby': PropTypes.string,
  55. 'aria-errormessage': PropTypes.string,
  56. 'aria-invalid': PropTypes.bool,
  57. 'aria-label': PropTypes.string,
  58. 'aria-labelledby': PropTypes.string,
  59. 'aria-required': PropTypes.bool,
  60. disabled: PropTypes.bool,
  61. value: PropTypes.number,
  62. defaultValue: PropTypes.number,
  63. count: PropTypes.number,
  64. allowHalf: PropTypes.bool,
  65. allowClear: PropTypes.bool,
  66. style: PropTypes.object,
  67. prefixCls: PropTypes.string,
  68. onChange: PropTypes.func,
  69. onHoverChange: PropTypes.func,
  70. className: PropTypes.string,
  71. character: PropTypes.node,
  72. tabIndex: PropTypes.number,
  73. onFocus: PropTypes.func,
  74. onBlur: PropTypes.func,
  75. onKeyDown: PropTypes.func,
  76. autoFocus: PropTypes.bool,
  77. size: PropTypes.oneOfType([PropTypes.oneOf(strings.SIZE_SET), PropTypes.number]),
  78. tooltips: PropTypes.arrayOf(PropTypes.string),
  79. id: PropTypes.string,
  80. preventScroll: PropTypes.bool,
  81. };
  82. static defaultProps = {
  83. defaultValue: 0,
  84. count: 5,
  85. allowHalf: false,
  86. allowClear: true,
  87. style: {},
  88. prefixCls: cssClasses.PREFIX,
  89. onChange: noop,
  90. onHoverChange: noop,
  91. tabIndex: -1,
  92. size: 'default' as const,
  93. };
  94. stars: Record<string, Item>;
  95. rate: HTMLUListElement = null;
  96. foundation: RatingFoundation;
  97. constructor(props: RatingProps) {
  98. super(props);
  99. const value = props.value === undefined ? props.defaultValue : props.value;
  100. this.stars = {};
  101. this.state = {
  102. value,
  103. focused: false,
  104. hoverValue: undefined,
  105. clearedValue: null,
  106. emptyStarFocusVisible: false,
  107. };
  108. this.foundation = new RatingFoundation(this.adapter);
  109. }
  110. static getDerivedStateFromProps(nextProps: RatingProps, state: RatingState) {
  111. if ('value' in nextProps && nextProps.value !== undefined) {
  112. return {
  113. ...state,
  114. value: nextProps.value,
  115. };
  116. }
  117. return state;
  118. }
  119. get adapter(): RatingAdapter<RatingProps, RatingState> {
  120. return {
  121. ...super.adapter,
  122. focus: () => {
  123. const { disabled, count } = this.props;
  124. const { value } = this.state;
  125. if (!disabled) {
  126. const index = Math.ceil(value) - 1;
  127. this.stars[index < 0 ? count : index].starFocus();
  128. }
  129. },
  130. getStarDOM: (index: number) => {
  131. const instance = this.stars && this.stars[index];
  132. // eslint-disable-next-line react/no-find-dom-node
  133. return ReactDOM.findDOMNode(instance) as Element;
  134. },
  135. notifyHoverChange: (hoverValue: number, clearedValue: number) => {
  136. const { onHoverChange } = this.props;
  137. this.setState({
  138. hoverValue,
  139. clearedValue,
  140. });
  141. onHoverChange(hoverValue);
  142. },
  143. updateValue: (value: number) => {
  144. const { onChange } = this.props;
  145. if (!('value' in this.props)) {
  146. this.setState({
  147. value,
  148. });
  149. }
  150. onChange(value);
  151. },
  152. clearValue: (clearedValue: number) => {
  153. this.setState({
  154. clearedValue,
  155. });
  156. },
  157. notifyFocus: (e: React.FocusEvent) => {
  158. const { onFocus } = this.props;
  159. this.setState({
  160. focused: true,
  161. });
  162. onFocus && onFocus(e);
  163. },
  164. notifyBlur: (e: React.FocusEvent) => {
  165. const { onBlur } = this.props;
  166. this.setState({
  167. focused: false,
  168. });
  169. onBlur && onBlur(e);
  170. },
  171. notifyKeyDown: (e: React.KeyboardEvent) => {
  172. const { onKeyDown } = this.props;
  173. this.setState({
  174. focused: false,
  175. });
  176. onKeyDown && onKeyDown(e);
  177. },
  178. setEmptyStarFocusVisible: (focusVisible: boolean): void => {
  179. this.setState({
  180. emptyStarFocusVisible: focusVisible,
  181. });
  182. },
  183. };
  184. }
  185. componentDidMount() {
  186. this.foundation.init();
  187. }
  188. componentWillUnmount() {
  189. this.foundation.destroy();
  190. }
  191. onHover = (event: React.MouseEvent, index: number) => {
  192. this.foundation.handleHover(event, index);
  193. };
  194. onMouseLeave = () => {
  195. this.foundation.handleMouseLeave();
  196. };
  197. onClick: RatingProps['onClick'] = (event, index) => {
  198. this.foundation.handleClick(event, index);
  199. };
  200. onFocus: RatingProps['onFocus'] = e => {
  201. this.foundation.handleFocus(e);
  202. };
  203. onBlur: RatingProps['onBlur'] = e => {
  204. this.foundation.handleBlur(e);
  205. };
  206. onKeyDown: RatingProps['onKeyDown'] = event => {
  207. const { value } = this.state;
  208. this.foundation.handleKeyDown(event, value);
  209. };
  210. focus = () => {
  211. const { disabled, preventScroll } = this.props;
  212. if (!disabled) {
  213. this.rate.focus({ preventScroll });
  214. }
  215. };
  216. blur = () => {
  217. const { disabled } = this.props;
  218. if (!disabled) {
  219. this.rate.blur();
  220. }
  221. };
  222. saveRef = (index: number) => (node: Item) => {
  223. this.stars[index] = node;
  224. };
  225. saveRate = (node: HTMLUListElement) => {
  226. this.rate = node;
  227. };
  228. handleStarFocusVisible = (event: React.FocusEvent) => {
  229. this.foundation.handleStarFocusVisible(event);
  230. }
  231. handleStarBlur = (event: React.FocusEvent) => {
  232. this.foundation.handleStarBlur(event);
  233. }
  234. getAriaLabelPrefix = () => {
  235. if (this.props['aria-label']) {
  236. return this.props['aria-label'];
  237. }
  238. let prefix = 'star';
  239. const { character } = this.props;
  240. if (typeof character === 'string') {
  241. prefix = character;
  242. }
  243. return prefix;
  244. }
  245. getItemList = (ariaLabelPrefix: string) => {
  246. const { count, allowHalf, prefixCls, disabled, character, size, tooltips } =this.props;
  247. const { value, hoverValue, focused } = this.state;
  248. // index == count is for Empty rating
  249. const itemList = [...Array(count + 1).keys()].map(ind => {
  250. const content = (
  251. <Item
  252. ref={this.saveRef(ind)}
  253. index={ind}
  254. count={count}
  255. prefixCls={`${prefixCls}-star`}
  256. allowHalf={allowHalf}
  257. value={hoverValue === undefined ? value : hoverValue}
  258. onClick={disabled ? noop : this.onClick}
  259. onHover={disabled ? noop : this.onHover}
  260. key={ind}
  261. disabled={disabled}
  262. character={character}
  263. focused={focused}
  264. size={ind === count ? 0 : size}
  265. ariaLabelPrefix={ariaLabelPrefix}
  266. onFocus={disabled || count !== ind ? noop : this.handleStarFocusVisible}
  267. onBlur={disabled || count !== ind ? noop : this.handleStarBlur}
  268. />
  269. );
  270. if (tooltips) {
  271. const text = tooltips[ind] ? tooltips[ind] : '';
  272. const showTips = hoverValue - 1 === ind;
  273. return (
  274. <Tooltip visible={showTips} trigger="custom" content={text} key={`${ind}-${showTips}`}>
  275. {content}
  276. </Tooltip>
  277. );
  278. }
  279. return content;
  280. });
  281. return itemList;
  282. }
  283. render() {
  284. const { style, prefixCls, disabled, className, id, count, tabIndex } = this.props;
  285. const { value, emptyStarFocusVisible } = this.state;
  286. const ariaLabelPrefix = this.getAriaLabelPrefix();
  287. const ariaLabel = `Rating: ${value} of ${count} ${ariaLabelPrefix}${value === 1 ? '' : 's'},`;
  288. const itemList = this.getItemList(ariaLabelPrefix);
  289. const listCls = cls(
  290. prefixCls,
  291. {
  292. [`${prefixCls}-disabled`]: disabled,
  293. [`${prefixCls}-focus`]: emptyStarFocusVisible,
  294. },
  295. className
  296. );
  297. return (
  298. // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
  299. <ul
  300. aria-label={ariaLabel}
  301. aria-labelledby={this.props['aria-labelledby']}
  302. aria-describedby={this.props['aria-describedby']}
  303. className={listCls}
  304. style={style}
  305. onMouseLeave={disabled ? noop : this.onMouseLeave}
  306. tabIndex={disabled ? -1 : tabIndex}
  307. onFocus={disabled ? noop : this.onFocus}
  308. onBlur={disabled ? noop : this.onBlur}
  309. onKeyDown={disabled ? noop : this.onKeyDown}
  310. ref={this.saveRate as any}
  311. id={id}
  312. >
  313. {itemList}
  314. </ul>
  315. );
  316. }
  317. }