123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540 |
- import React, { AriaAttributes } from 'react';
- import BaseComponent from '../_base/baseComponent';
- import PropTypes from 'prop-types';
- import classnames from 'classnames';
- import { noop, debounce, throttle, find, map, findIndex, times } from 'lodash';
- import { cssClasses, numbers, strings } from '@douyinfe/semi-foundation/scrollList/constants';
- import ItemFoundation, { Item, ScrollItemAdapter } from '@douyinfe/semi-foundation/scrollList/itemFoundation';
- import animatedScrollTo from '@douyinfe/semi-foundation/scrollList/scrollTo';
- import isElement from '@douyinfe/semi-foundation/utils/isElement';
- import { Motion } from '../_base/base';
- const msPerFrame = 1000 / 60;
- const blankReg = /^\s*$/;
- const wheelMode = 'wheel';
- interface DebounceSelectFn {
- (e: React.UIEvent, newSelectedNode: HTMLElement): void;
- cancel(): void
- }
- export interface ScrollItemProps<T extends Item> {
- mode?: string;
- cycled?: boolean;
- list?: T[];
- selectedIndex?: number;
- onSelect?: (data: T) => void;
- transform?: (value: any, text: string) => string;
- className?: string;
- motion?: Motion;
- style?: React.CSSProperties;
- type?: string | number; // used to identify the scrollItem, used internally by the semi component, and does not need to be exposed to the user
- 'aria-label'?: AriaAttributes['aria-label']
- }
- export interface ScrollItemState {
- prependCount: number;
- appendCount: number
- }
- export default class ScrollItem<T extends Item> extends BaseComponent<ScrollItemProps<T>, ScrollItemState> {
- static propTypes = {
- mode: PropTypes.oneOf(strings.MODE),
- cycled: PropTypes.bool,
- list: PropTypes.array,
- selectedIndex: PropTypes.number,
- onSelect: PropTypes.func,
- transform: PropTypes.func,
- className: PropTypes.string,
- style: PropTypes.object,
- motion: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
- type: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
- };
- static defaultProps = {
- selectedIndex: 0,
- motion: true,
- // transform: identity,
- list: [] as const,
- onSelect: noop,
- cycled: false,
- mode: wheelMode,
- };
- selectedNode: HTMLElement;
- willSelectNode: HTMLElement;
- list: HTMLElement;
- wrapper: HTMLElement;
- selector: unknown;
- scrollAnimation: any;
- scrolling: boolean;
- throttledAdjustList: DebounceSelectFn;
- debouncedSelect: DebounceSelectFn;
- constructor(props = {}) {
- super(props);
- this.state = {
- prependCount: 0,
- appendCount: 0,
- // selectedIndex: props.selectedIndex,
- // fakeSelectedIndex: props.selectedIndex,
- };
- this.selectedNode = null;
- this.willSelectNode = null;
- this.list = null;
- this.wrapper = null;
- this.selector = null;
- this.scrollAnimation = null;
- // cache if select action comes from outside
- this.foundation = new ItemFoundation<ScrollItemProps<T>, ScrollItemState, T>(this.adapter);
- this.throttledAdjustList = throttle((e, nearestNode) => {
- this.foundation.adjustInfiniteList(this.list, this.wrapper, nearestNode);
- }, msPerFrame);
- this.debouncedSelect = debounce((e, nearestNode) => {
- this._cacheSelectedNode(nearestNode);
- this.foundation.selectNode(nearestNode, this.list);
- }, msPerFrame * 2);
- }
- get adapter(): ScrollItemAdapter<ScrollItemProps<T>, ScrollItemState, T> {
- return {
- ...super.adapter,
- setState: (states, callback) => this.setState({ ...states } as ScrollItemState, callback),
- setPrependCount: prependCount => this.setState({ prependCount }),
- setAppendCount: appendCount => this.setState({ appendCount }),
- isDisabledIndex: this.isDisabledIndex,
- setSelectedNode: selectedNode => this._cacheWillSelectNode(selectedNode),
- notifySelectItem: (...args) => this.props.onSelect(...args),
- scrollToCenter: this.scrollToCenter,
- };
- }
- componentWillUnmount() {
- if (this.props.cycled) {
- this.throttledAdjustList.cancel();
- this.debouncedSelect.cancel();
- }
- }
- componentDidMount() {
- this.foundation.init();
- const { mode, cycled, selectedIndex, list } = this.props;
- const selectedNode = this.getNodeByIndex(
- typeof selectedIndex === 'number' && selectedIndex > -1 ? selectedIndex : 0
- ) as HTMLElement;
- this._cacheSelectedNode(selectedNode);
- this._cacheWillSelectNode(selectedNode);
- if (mode === wheelMode && cycled) {
- this.foundation.initWheelList(this.list, this.wrapper, () => {
- // we have to scroll in next tick
- // setTimeout(() => {
- this.scrollToNode(selectedNode, 0);
- // });
- });
- } else {
- this.scrollToNode(selectedNode, 0);
- }
- }
- componentDidUpdate(prevProps: ScrollItemProps<T>) {
- const { selectedIndex } = this.props;
- // smooth scroll to selected option
- if (prevProps.selectedIndex !== selectedIndex) {
- const willSelectIndex = this.getIndexByNode(this.willSelectNode);
- if (!this.indexIsSame(willSelectIndex, selectedIndex)) {
- const newSelectedNode = this.getNodeByOffset(
- this.selectedNode,
- selectedIndex - prevProps.selectedIndex,
- this.list
- );
- this._cacheWillSelectNode(newSelectedNode);
- }
- this._cacheSelectedNode(this.willSelectNode);
- this.scrollToIndex(selectedIndex);
- }
- }
- _cacheNode = (name: string, node: Element) =>
- name && node && Object.prototype.hasOwnProperty.call(this, name) && (this[name] = node);
- _cacheSelectedNode = (selectedNode: Element) => this._cacheNode('selectedNode', selectedNode);
- _cacheWillSelectNode = (node: Element) => this._cacheNode('willSelectNode', node);
- _cacheListNode = (list: Element) => this._cacheNode('list', list);
- _cacheSelectorNode = (selector: Element) => this._cacheNode('selector', selector);
- _cacheWrapperNode = (wrapper: Element) => this._cacheNode('wrapper', wrapper);
- /* istanbul ignore next */
- _isFirst = (node: Element) => {
- const { list } = this;
- if (isElement(node) && isElement(list)) {
- const chilren = list.children;
- const index = findIndex(chilren, node);
- return index === 0;
- }
- return false;
- };
- /* istanbul ignore next */
- _isLast = (node: Element) => {
- const { list } = this;
- if (isElement(node) && isElement(list)) {
- const { children } = list;
- const index = findIndex(children, node);
- return index === children.length - 1;
- }
- return false;
- };
- /**
- *
- * @param {HTMLElement} refNode
- * @param {number} offset
- * @param {HTMLElement} listWrapper
- *
- * @returns {HTMLElement}
- */
- getNodeByOffset(refNode: Element, offset: number, listWrapper: Element) {
- const { list } = this.props;
- if (
- isElement(refNode) &&
- isElement(listWrapper) &&
- typeof offset === 'number' &&
- Array.isArray(list) &&
- list.length
- ) {
- offset = offset % list.length;
- const refIndex = this.getIndexByNode(refNode);
- let targetIndex = refIndex + offset;
- while (targetIndex < 0) {
- targetIndex += list.length;
- }
- if (offset) {
- return this.getNodeByIndex(targetIndex);
- }
- }
- return refNode;
- }
- indexIsSame = (index1: number, index2: number) => {
- const { list } = this.props;
- if (list.length) {
- return index1 % list.length === index2 % list.length;
- }
- return undefined;
- };
- isDisabledIndex = (index: number) => {
- const { list } = this.props;
- if (Array.isArray(list) && list.length && index > -1) {
- const size = list.length;
- const indexInData = index % size;
- return this.isDisabledData(list[indexInData]);
- }
- return false;
- };
- isDisabledNode = (node: Element) => {
- const listWrapper = this.list;
- if (isElement(node) && isElement(listWrapper)) {
- const index = findIndex(listWrapper.children, child => child === node);
- return this.isDisabledIndex(index);
- }
- return false;
- };
- isDisabledData = (data: T) => data && typeof data === 'object' && data.disabled;
- isWheelMode = () => this.props.mode === wheelMode;
- addClassToNode = (selectedNode: Element, selectedCls = cssClasses.SELECTED) => {
- const { list } = this;
- selectedNode = selectedNode || this.selectedNode;
- if (isElement(selectedNode) && isElement(list)) {
- const { children } = list;
- const reg = new RegExp(`\\s*${selectedCls}\\s*`, 'g');
- map(children, node => {
- node.className = node.className && node.className.replace(reg, ' ');
- if (blankReg.test(node.className)) {
- node.className = '';
- }
- });
- if (selectedNode.className && !blankReg.test(selectedNode.className)) {
- selectedNode.className += ` ${selectedCls}`;
- } else {
- selectedNode.className = selectedCls;
- }
- }
- };
- getIndexByNode = (node: Element) => findIndex(this.list.children, node);
- getNodeByIndex = (index: number) => {
- if (index > -1) {
- return find(this.list.children, (node, idx) => idx === index);
- }
- const defaultSelectedNode = find(this.list.children, child => !this.isDisabledNode(child));
- return defaultSelectedNode;
- };
- scrollToIndex = (selectedIndex: number, duration?: number) => {
- // move to selected item
- duration = typeof duration === 'number' ? duration : numbers.DEFAULT_SCROLL_DURATION;
- // eslint-disable-next-line
- selectedIndex = selectedIndex == null ? this.props.selectedIndex : selectedIndex;
- // this.isWheelMode() && this.addClassToNode();
- this.scrollToNode(this.selectedNode, duration);
- };
- scrollToNode = (node: HTMLElement, duration: number) => {
- const { wrapper } = this;
- const wrapperHeight = wrapper.offsetHeight;
- const itemHeight = this.getItmHeight(node);
- const targetTop = (node.offsetTop || this.list.children.length * itemHeight / 2) - (wrapperHeight - itemHeight) / 2;
- this.scrollToPos(targetTop, duration);
- };
- scrollToPos = (targetTop: number, duration = numbers.DEFAULT_SCROLL_DURATION) => {
- const { wrapper } = this;
- // this.isWheelMode() && this.addClassToNode();
- if (duration && this.props.motion) {
- if (this.scrollAnimation) {
- this.scrollAnimation.destroy();
- this.scrolling = false;
- }
- if (wrapper.scrollTop === targetTop) {
- if (this.isWheelMode()) {
- const nodeInfo = this.foundation.getNearestNodeInfo(this.list, this.selector);
- this.addClassToNode(nodeInfo.nearestNode);
- }
- } else {
- this.scrollAnimation = animatedScrollTo(wrapper, targetTop, duration);
- this.scrollAnimation.on('rest', () => {
- if (this.isWheelMode()) {
- const nodeInfo = this.foundation.getNearestNodeInfo(this.list, this.selector);
- this.addClassToNode(nodeInfo.nearestNode);
- }
- });
- this.scrollAnimation.start();
- }
- } else {
- wrapper.scrollTop = targetTop;
- }
- };
- scrollToSelectItem: React.UIEventHandler = e => {
- const { nearestNode } = this.foundation.getNearestNodeInfo(this.list, this.selector);
- if (this.props.cycled) {
- this.throttledAdjustList(e, nearestNode);
- }
- this.debouncedSelect(e, nearestNode);
- };
- /**
- *
- * reset position to center of the scrollWrapper
- *
- * @param {HTMLElement} selectedNode
- * @param {HTMLElement} scrollWnumber
- * @param {number} duration
- */
- scrollToCenter: ScrollItemAdapter['scrollToCenter'] = (selectedNode, scrollWrapper, duration) => {
- selectedNode = selectedNode || this.selectedNode;
- scrollWrapper = scrollWrapper || this.wrapper;
- if (isElement(selectedNode) && isElement(scrollWrapper)) {
- const scrollRect = scrollWrapper.getBoundingClientRect();
- const selectedRect = selectedNode.getBoundingClientRect();
- const targetTop =
- scrollWrapper.scrollTop +
- (selectedRect.top - (scrollRect.top + scrollRect.height / 2 - selectedRect.height / 2));
- this.scrollToPos(targetTop, typeof duration === 'number' ? duration : numbers.DEFAULT_SCROLL_DURATION);
- }
- };
- clickToSelectItem: React.MouseEventHandler = e => {
- // const index = this.foundation.selectNearestIndex(e.nativeEvent, this.list);
- e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation();
- const { targetNode: node, infoInList } = this.foundation.getTargetNode(e, this.list);
- if (node && infoInList && !infoInList.disabled) {
- this.debouncedSelect(null, node);
- }
- };
- getItmHeight = (itm: HTMLElement) => (itm && itm.offsetHeight) || numbers.DEFAULT_ITEM_HEIGHT;
- renderItemList = (prefixKey = '') => {
- const { selectedIndex, mode, transform: commonTrans, list } = this.props;
- return list.map((item, index) => {
- const { transform: itemTrans } = item;
- const transform = typeof itemTrans === 'function' ? itemTrans : commonTrans;
- const selected = selectedIndex === index;
- const cls = classnames({
- [`${cssClasses.PREFIX}-item-sel`]: selected && mode !== wheelMode,
- [`${cssClasses.PREFIX}-item-disabled`]: Boolean(item.disabled),
- });
- let text = '';
- if (selected) {
- if (typeof transform === 'function') {
- text = transform(item.value, item.text);
- } else {
- // eslint-disable-next-line
- text = item.text == null ? item.value : item.text;
- }
- } else {
- // eslint-disable-next-line
- text = item.text == null ? item.value : item.text;
- }
- const events: { onClick?: () => void } = {};
- if (!this.isWheelMode() && !item.disabled) {
- events.onClick = () => this.foundation.selectIndex(index, this.list);
- }
- return (
- // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
- <li
- key={prefixKey + index}
- {...events}
- className={cls}
- role="option"
- aria-selected={selected}
- aria-disabled={item.disabled}
- >
- {text}
- </li>
- );
- });
- };
- renderNormalList = () => {
- const { list, className, style } = this.props;
- const inner = this.renderItemList();
- const wrapperCls = classnames(`${cssClasses.PREFIX}-item`, className);
- return (
- <div style={style} className={wrapperCls} ref={this._cacheWrapperNode}>
- <ul
- role="listbox"
- aria-multiselectable={false}
- aria-label={this.props['aria-label']}
- ref={this._cacheListNode}
- >
- {inner}
- </ul>
- </div>
- );
- };
- /**
- * List of Rendering Unlimited Modes
- */
- renderInfiniteList = () => {
- const { list, cycled, className, style } = this.props;
- const { prependCount, appendCount } = this.state;
- const prependList = times(prependCount).reduce((arr, num) => {
- const items = this.renderItemList(`pre_${num}_`);
- arr.unshift(...items);
- return arr;
- }, []);
- const appendList = times(appendCount).reduce((arr, num) => {
- const items = this.renderItemList(`app_${num}_`);
- arr.push(...items);
- return arr;
- }, []);
- const inner = this.renderItemList();
- const listWrapperCls = classnames(`${cssClasses.PREFIX}-list-outer`, {
- [`${cssClasses.PREFIX}-list-outer-nocycle`]: !cycled,
- });
- const wrapperCls = classnames(`${cssClasses.PREFIX}-item-wheel`, className);
- const selectorCls = classnames(`${cssClasses.PREFIX}-selector`);
- const preShadeCls = classnames(`${cssClasses.PREFIX}-shade`, `${cssClasses.PREFIX}-shade-pre`);
- const postShadeCls = classnames(`${cssClasses.PREFIX}-shade`, `${cssClasses.PREFIX}-shade-post`);
- return (
- <div className={wrapperCls} style={style}>
- <div className={preShadeCls} />
- <div className={selectorCls} ref={this._cacheSelectorNode} />
- <div className={postShadeCls} />
- <div className={listWrapperCls} ref={this._cacheWrapperNode} onScroll={this.scrollToSelectItem}>
- <ul
- role="listbox"
- aria-label={this.props['aria-label']}
- aria-multiselectable={false}
- ref={this._cacheListNode}
- onClick={this.clickToSelectItem}
- >
- {prependList}
- {inner}
- {appendList}
- </ul>
- </div>
- </div>
- );
- };
- render() {
- return this.isWheelMode() ? this.renderInfiniteList() : this.renderNormalList();
- }
- }
|