scrollItem.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. import React, { AriaAttributes } from 'react';
  2. import BaseComponent from '../_base/baseComponent';
  3. import PropTypes from 'prop-types';
  4. import classnames from 'classnames';
  5. import { noop, debounce, throttle, find, map, findIndex, times } from 'lodash';
  6. import { cssClasses, numbers, strings } from '@douyinfe/semi-foundation/scrollList/constants';
  7. import ItemFoundation, { Item, ScrollItemAdapter } from '@douyinfe/semi-foundation/scrollList/itemFoundation';
  8. import animatedScrollTo from '@douyinfe/semi-foundation/scrollList/scrollTo';
  9. import isElement from '@douyinfe/semi-foundation/utils/isElement';
  10. import { Motion } from '../_base/base';
  11. const msPerFrame = 1000 / 60;
  12. const blankReg = /^\s*$/;
  13. const wheelMode = 'wheel';
  14. interface DebounceSelectFn {
  15. (e: React.UIEvent, newSelectedNode: HTMLElement): void;
  16. cancel(): void
  17. }
  18. export interface ScrollItemProps<T extends Item> {
  19. mode?: string;
  20. cycled?: boolean;
  21. list?: T[];
  22. selectedIndex?: number;
  23. onSelect?: (data: T) => void;
  24. transform?: (value: any, text: string) => string;
  25. className?: string;
  26. motion?: Motion;
  27. style?: React.CSSProperties;
  28. type?: string | number; // used to identify the scrollItem, used internally by the semi component, and does not need to be exposed to the user
  29. 'aria-label'?: AriaAttributes['aria-label']
  30. }
  31. export interface ScrollItemState {
  32. prependCount: number;
  33. appendCount: number
  34. }
  35. export default class ScrollItem<T extends Item> extends BaseComponent<ScrollItemProps<T>, ScrollItemState> {
  36. static propTypes = {
  37. mode: PropTypes.oneOf(strings.MODE),
  38. cycled: PropTypes.bool,
  39. list: PropTypes.array,
  40. selectedIndex: PropTypes.number,
  41. onSelect: PropTypes.func,
  42. transform: PropTypes.func,
  43. className: PropTypes.string,
  44. style: PropTypes.object,
  45. motion: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
  46. type: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  47. };
  48. static defaultProps = {
  49. selectedIndex: 0,
  50. motion: true,
  51. // transform: identity,
  52. list: [] as const,
  53. onSelect: noop,
  54. cycled: false,
  55. mode: wheelMode,
  56. };
  57. selectedNode: HTMLElement;
  58. willSelectNode: HTMLElement;
  59. list: HTMLElement;
  60. wrapper: HTMLElement;
  61. selector: unknown;
  62. scrollAnimation: any;
  63. scrolling: boolean;
  64. throttledAdjustList: DebounceSelectFn;
  65. debouncedSelect: DebounceSelectFn;
  66. constructor(props = {}) {
  67. super(props);
  68. this.state = {
  69. prependCount: 0,
  70. appendCount: 0,
  71. // selectedIndex: props.selectedIndex,
  72. // fakeSelectedIndex: props.selectedIndex,
  73. };
  74. this.selectedNode = null;
  75. this.willSelectNode = null;
  76. this.list = null;
  77. this.wrapper = null;
  78. this.selector = null;
  79. this.scrollAnimation = null;
  80. // cache if select action comes from outside
  81. this.foundation = new ItemFoundation<ScrollItemProps<T>, ScrollItemState, T>(this.adapter);
  82. this.throttledAdjustList = throttle((e, nearestNode) => {
  83. this.foundation.adjustInfiniteList(this.list, this.wrapper, nearestNode);
  84. }, msPerFrame);
  85. this.debouncedSelect = debounce((e, nearestNode) => {
  86. this._cacheSelectedNode(nearestNode);
  87. this.foundation.selectNode(nearestNode, this.list);
  88. }, msPerFrame * 2);
  89. }
  90. get adapter(): ScrollItemAdapter<ScrollItemProps<T>, ScrollItemState, T> {
  91. return {
  92. ...super.adapter,
  93. setState: (states, callback) => this.setState({ ...states } as ScrollItemState, callback),
  94. setPrependCount: prependCount => this.setState({ prependCount }),
  95. setAppendCount: appendCount => this.setState({ appendCount }),
  96. isDisabledIndex: this.isDisabledIndex,
  97. setSelectedNode: selectedNode => this._cacheWillSelectNode(selectedNode),
  98. notifySelectItem: (...args) => this.props.onSelect(...args),
  99. scrollToCenter: this.scrollToCenter,
  100. };
  101. }
  102. componentWillUnmount() {
  103. if (this.props.cycled) {
  104. this.throttledAdjustList.cancel();
  105. this.debouncedSelect.cancel();
  106. }
  107. }
  108. componentDidMount() {
  109. this.foundation.init();
  110. const { mode, cycled, selectedIndex, list } = this.props;
  111. const selectedNode = this.getNodeByIndex(
  112. typeof selectedIndex === 'number' && selectedIndex > -1 ? selectedIndex : 0
  113. ) as HTMLElement;
  114. this._cacheSelectedNode(selectedNode);
  115. this._cacheWillSelectNode(selectedNode);
  116. if (mode === wheelMode && cycled) {
  117. this.foundation.initWheelList(this.list, this.wrapper, () => {
  118. // we have to scroll in next tick
  119. // setTimeout(() => {
  120. this.scrollToNode(selectedNode, 0);
  121. // });
  122. });
  123. } else {
  124. this.scrollToNode(selectedNode, 0);
  125. }
  126. }
  127. componentDidUpdate(prevProps: ScrollItemProps<T>) {
  128. const { selectedIndex } = this.props;
  129. // smooth scroll to selected option
  130. if (prevProps.selectedIndex !== selectedIndex) {
  131. const willSelectIndex = this.getIndexByNode(this.willSelectNode);
  132. if (!this.indexIsSame(willSelectIndex, selectedIndex)) {
  133. const newSelectedNode = this.getNodeByOffset(
  134. this.selectedNode,
  135. selectedIndex - prevProps.selectedIndex,
  136. this.list
  137. );
  138. this._cacheWillSelectNode(newSelectedNode);
  139. }
  140. this._cacheSelectedNode(this.willSelectNode);
  141. this.scrollToIndex(selectedIndex);
  142. }
  143. }
  144. _cacheNode = (name: string, node: Element) =>
  145. name && node && Object.prototype.hasOwnProperty.call(this, name) && (this[name] = node);
  146. _cacheSelectedNode = (selectedNode: Element) => this._cacheNode('selectedNode', selectedNode);
  147. _cacheWillSelectNode = (node: Element) => this._cacheNode('willSelectNode', node);
  148. _cacheListNode = (list: Element) => this._cacheNode('list', list);
  149. _cacheSelectorNode = (selector: Element) => this._cacheNode('selector', selector);
  150. _cacheWrapperNode = (wrapper: Element) => this._cacheNode('wrapper', wrapper);
  151. /* istanbul ignore next */
  152. _isFirst = (node: Element) => {
  153. const { list } = this;
  154. if (isElement(node) && isElement(list)) {
  155. const chilren = list.children;
  156. const index = findIndex(chilren, node);
  157. return index === 0;
  158. }
  159. return false;
  160. };
  161. /* istanbul ignore next */
  162. _isLast = (node: Element) => {
  163. const { list } = this;
  164. if (isElement(node) && isElement(list)) {
  165. const { children } = list;
  166. const index = findIndex(children, node);
  167. return index === children.length - 1;
  168. }
  169. return false;
  170. };
  171. /**
  172. *
  173. * @param {HTMLElement} refNode
  174. * @param {number} offset
  175. * @param {HTMLElement} listWrapper
  176. *
  177. * @returns {HTMLElement}
  178. */
  179. getNodeByOffset(refNode: Element, offset: number, listWrapper: Element) {
  180. const { list } = this.props;
  181. if (
  182. isElement(refNode) &&
  183. isElement(listWrapper) &&
  184. typeof offset === 'number' &&
  185. Array.isArray(list) &&
  186. list.length
  187. ) {
  188. offset = offset % list.length;
  189. const refIndex = this.getIndexByNode(refNode);
  190. let targetIndex = refIndex + offset;
  191. while (targetIndex < 0) {
  192. targetIndex += list.length;
  193. }
  194. if (offset) {
  195. return this.getNodeByIndex(targetIndex);
  196. }
  197. }
  198. return refNode;
  199. }
  200. indexIsSame = (index1: number, index2: number) => {
  201. const { list } = this.props;
  202. if (list.length) {
  203. return index1 % list.length === index2 % list.length;
  204. }
  205. return undefined;
  206. };
  207. isDisabledIndex = (index: number) => {
  208. const { list } = this.props;
  209. if (Array.isArray(list) && list.length && index > -1) {
  210. const size = list.length;
  211. const indexInData = index % size;
  212. return this.isDisabledData(list[indexInData]);
  213. }
  214. return false;
  215. };
  216. isDisabledNode = (node: Element) => {
  217. const listWrapper = this.list;
  218. if (isElement(node) && isElement(listWrapper)) {
  219. const index = findIndex(listWrapper.children, child => child === node);
  220. return this.isDisabledIndex(index);
  221. }
  222. return false;
  223. };
  224. isDisabledData = (data: T) => data && typeof data === 'object' && data.disabled;
  225. isWheelMode = () => this.props.mode === wheelMode;
  226. addClassToNode = (selectedNode: Element, selectedCls = cssClasses.SELECTED) => {
  227. const { list } = this;
  228. selectedNode = selectedNode || this.selectedNode;
  229. if (isElement(selectedNode) && isElement(list)) {
  230. const { children } = list;
  231. const reg = new RegExp(`\\s*${selectedCls}\\s*`, 'g');
  232. map(children, node => {
  233. node.className = node.className && node.className.replace(reg, ' ');
  234. if (blankReg.test(node.className)) {
  235. node.className = '';
  236. }
  237. });
  238. if (selectedNode.className && !blankReg.test(selectedNode.className)) {
  239. selectedNode.className += ` ${selectedCls}`;
  240. } else {
  241. selectedNode.className = selectedCls;
  242. }
  243. }
  244. };
  245. getIndexByNode = (node: Element) => findIndex(this.list.children, node);
  246. getNodeByIndex = (index: number) => {
  247. if (index > -1) {
  248. return find(this.list.children, (node, idx) => idx === index);
  249. }
  250. const defaultSelectedNode = find(this.list.children, child => !this.isDisabledNode(child));
  251. return defaultSelectedNode;
  252. };
  253. scrollToIndex = (selectedIndex: number, duration?: number) => {
  254. // move to selected item
  255. duration = typeof duration === 'number' ? duration : numbers.DEFAULT_SCROLL_DURATION;
  256. // eslint-disable-next-line
  257. selectedIndex = selectedIndex == null ? this.props.selectedIndex : selectedIndex;
  258. // this.isWheelMode() && this.addClassToNode();
  259. this.scrollToNode(this.selectedNode, duration);
  260. };
  261. scrollToNode = (node: HTMLElement, duration: number) => {
  262. const { wrapper } = this;
  263. const wrapperHeight = wrapper.offsetHeight;
  264. const itemHeight = this.getItmHeight(node);
  265. const targetTop = (node.offsetTop || this.list.children.length * itemHeight / 2) - (wrapperHeight - itemHeight) / 2;
  266. this.scrollToPos(targetTop, duration);
  267. };
  268. scrollToPos = (targetTop: number, duration = numbers.DEFAULT_SCROLL_DURATION) => {
  269. const { wrapper } = this;
  270. // this.isWheelMode() && this.addClassToNode();
  271. if (duration && this.props.motion) {
  272. if (this.scrollAnimation) {
  273. this.scrollAnimation.destroy();
  274. this.scrolling = false;
  275. }
  276. if (wrapper.scrollTop === targetTop) {
  277. if (this.isWheelMode()) {
  278. const nodeInfo = this.foundation.getNearestNodeInfo(this.list, this.selector);
  279. this.addClassToNode(nodeInfo.nearestNode);
  280. }
  281. } else {
  282. this.scrollAnimation = animatedScrollTo(wrapper, targetTop, duration);
  283. this.scrollAnimation.on('rest', () => {
  284. if (this.isWheelMode()) {
  285. const nodeInfo = this.foundation.getNearestNodeInfo(this.list, this.selector);
  286. this.addClassToNode(nodeInfo.nearestNode);
  287. }
  288. });
  289. this.scrollAnimation.start();
  290. }
  291. } else {
  292. wrapper.scrollTop = targetTop;
  293. }
  294. };
  295. scrollToSelectItem: React.UIEventHandler = e => {
  296. const { nearestNode } = this.foundation.getNearestNodeInfo(this.list, this.selector);
  297. if (this.props.cycled) {
  298. this.throttledAdjustList(e, nearestNode);
  299. }
  300. this.debouncedSelect(e, nearestNode);
  301. };
  302. /**
  303. *
  304. * reset position to center of the scrollWrapper
  305. *
  306. * @param {HTMLElement} selectedNode
  307. * @param {HTMLElement} scrollWnumber
  308. * @param {number} duration
  309. */
  310. scrollToCenter: ScrollItemAdapter['scrollToCenter'] = (selectedNode, scrollWrapper, duration) => {
  311. selectedNode = selectedNode || this.selectedNode;
  312. scrollWrapper = scrollWrapper || this.wrapper;
  313. if (isElement(selectedNode) && isElement(scrollWrapper)) {
  314. const scrollRect = scrollWrapper.getBoundingClientRect();
  315. const selectedRect = selectedNode.getBoundingClientRect();
  316. const targetTop =
  317. scrollWrapper.scrollTop +
  318. (selectedRect.top - (scrollRect.top + scrollRect.height / 2 - selectedRect.height / 2));
  319. this.scrollToPos(targetTop, typeof duration === 'number' ? duration : numbers.DEFAULT_SCROLL_DURATION);
  320. }
  321. };
  322. clickToSelectItem: React.MouseEventHandler = e => {
  323. // const index = this.foundation.selectNearestIndex(e.nativeEvent, this.list);
  324. e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation();
  325. const { targetNode: node, infoInList } = this.foundation.getTargetNode(e, this.list);
  326. if (node && infoInList && !infoInList.disabled) {
  327. this.debouncedSelect(null, node);
  328. }
  329. };
  330. getItmHeight = (itm: HTMLElement) => (itm && itm.offsetHeight) || numbers.DEFAULT_ITEM_HEIGHT;
  331. renderItemList = (prefixKey = '') => {
  332. const { selectedIndex, mode, transform: commonTrans, list } = this.props;
  333. return list.map((item, index) => {
  334. const { transform: itemTrans } = item;
  335. const transform = typeof itemTrans === 'function' ? itemTrans : commonTrans;
  336. const selected = selectedIndex === index;
  337. const cls = classnames({
  338. [`${cssClasses.PREFIX}-item-sel`]: selected && mode !== wheelMode,
  339. [`${cssClasses.PREFIX}-item-disabled`]: Boolean(item.disabled),
  340. });
  341. let text = '';
  342. if (selected) {
  343. if (typeof transform === 'function') {
  344. text = transform(item.value, item.text);
  345. } else {
  346. // eslint-disable-next-line
  347. text = item.text == null ? item.value : item.text;
  348. }
  349. } else {
  350. // eslint-disable-next-line
  351. text = item.text == null ? item.value : item.text;
  352. }
  353. const events: { onClick?: () => void } = {};
  354. if (!this.isWheelMode() && !item.disabled) {
  355. events.onClick = () => this.foundation.selectIndex(index, this.list);
  356. }
  357. return (
  358. // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
  359. <li
  360. key={prefixKey + index}
  361. {...events}
  362. className={cls}
  363. role="option"
  364. aria-selected={selected}
  365. aria-disabled={item.disabled}
  366. >
  367. {text}
  368. </li>
  369. );
  370. });
  371. };
  372. renderNormalList = () => {
  373. const { list, className, style } = this.props;
  374. const inner = this.renderItemList();
  375. const wrapperCls = classnames(`${cssClasses.PREFIX}-item`, className);
  376. return (
  377. <div style={style} className={wrapperCls} ref={this._cacheWrapperNode}>
  378. <ul
  379. role="listbox"
  380. aria-multiselectable={false}
  381. aria-label={this.props['aria-label']}
  382. ref={this._cacheListNode}
  383. >
  384. {inner}
  385. </ul>
  386. </div>
  387. );
  388. };
  389. /**
  390. * List of Rendering Unlimited Modes
  391. */
  392. renderInfiniteList = () => {
  393. const { list, cycled, className, style } = this.props;
  394. const { prependCount, appendCount } = this.state;
  395. const prependList = times(prependCount).reduce((arr, num) => {
  396. const items = this.renderItemList(`pre_${num}_`);
  397. arr.unshift(...items);
  398. return arr;
  399. }, []);
  400. const appendList = times(appendCount).reduce((arr, num) => {
  401. const items = this.renderItemList(`app_${num}_`);
  402. arr.push(...items);
  403. return arr;
  404. }, []);
  405. const inner = this.renderItemList();
  406. const listWrapperCls = classnames(`${cssClasses.PREFIX}-list-outer`, {
  407. [`${cssClasses.PREFIX}-list-outer-nocycle`]: !cycled,
  408. });
  409. const wrapperCls = classnames(`${cssClasses.PREFIX}-item-wheel`, className);
  410. const selectorCls = classnames(`${cssClasses.PREFIX}-selector`);
  411. const preShadeCls = classnames(`${cssClasses.PREFIX}-shade`, `${cssClasses.PREFIX}-shade-pre`);
  412. const postShadeCls = classnames(`${cssClasses.PREFIX}-shade`, `${cssClasses.PREFIX}-shade-post`);
  413. return (
  414. <div className={wrapperCls} style={style}>
  415. <div className={preShadeCls} />
  416. <div className={selectorCls} ref={this._cacheSelectorNode} />
  417. <div className={postShadeCls} />
  418. <div className={listWrapperCls} ref={this._cacheWrapperNode} onScroll={this.scrollToSelectItem}>
  419. <ul
  420. role="listbox"
  421. aria-label={this.props['aria-label']}
  422. aria-multiselectable={false}
  423. ref={this._cacheListNode}
  424. onClick={this.clickToSelectItem}
  425. >
  426. {prependList}
  427. {inner}
  428. {appendList}
  429. </ul>
  430. </div>
  431. </div>
  432. );
  433. };
  434. render() {
  435. return this.isWheelMode() ? this.renderInfiniteList() : this.renderNormalList();
  436. }
  437. }