index.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. /* eslint-disable arrow-body-style */
  2. import React, { CSSProperties, ReactNode, MutableRefObject, RefCallback, Key, ReactElement } from 'react';
  3. import cls from 'classnames';
  4. import BaseComponent from '../_base/baseComponent';
  5. import PropTypes from 'prop-types';
  6. import { isEqual, omit, isNull, isUndefined } from 'lodash';
  7. import { cssClasses, strings } from '@douyinfe/semi-foundation/overflowList/constants';
  8. import ResizeObserver, { ResizeEntry } from '../resizeObserver';
  9. import IntersectionObserver from './intersectionObserver';
  10. import OverflowListFoundation, { OverflowListAdapter } from '@douyinfe/semi-foundation/overflowList/foundation';
  11. import '@douyinfe/semi-foundation/overflowList/overflowList.scss';
  12. const prefixCls = cssClasses.PREFIX;
  13. const Boundary = strings.BOUNDARY_MAP;
  14. const OverflowDirection = strings.OVERFLOW_DIR;
  15. const RenderMode = strings.MODE_MAP;
  16. export { ReactIntersectionObserverProps } from './intersectionObserver';
  17. export type OverflowItem = Record<string, any>;
  18. export interface OverflowListProps {
  19. className?: string;
  20. collapseFrom?: 'start' | 'end';
  21. items?: Array<OverflowItem>;
  22. minVisibleItems?: number;
  23. onIntersect?: (res: { [key: string]: IntersectionObserverEntry }) => void;
  24. onOverflow?: (overflowItems: Array<OverflowItem>) => void;
  25. overflowRenderer?: (overflowItems: Array<OverflowItem>) => ReactNode | ReactNode[];
  26. renderMode?: 'collapse' | 'scroll';
  27. style?: CSSProperties;
  28. threshold?: number;
  29. visibleItemRenderer?: (item: OverflowItem, index: number) => ReactElement;
  30. wrapperClassName?: string;
  31. wrapperStyle?: CSSProperties;
  32. }
  33. export interface OverflowListState {
  34. direction?: typeof OverflowDirection.GROW;
  35. lastOverflowCount?: number;
  36. overflow?: Array<OverflowItem>;
  37. visible?: Array<OverflowItem>;
  38. visibleState?: Map<string, boolean>;
  39. prevProps?: OverflowListProps;
  40. }
  41. // reference to https://github.com/palantir/blueprint/blob/1aa71605/packages/core/src/components/overflow-list/overflowList.tsx#L34
  42. class OverflowList extends BaseComponent<OverflowListProps, OverflowListState> {
  43. static defaultProps = {
  44. collapseFrom: 'end',
  45. minVisibleItems: 0,
  46. overflowRenderer: (): ReactElement => null,
  47. renderMode: 'collapse',
  48. threshold: 0.75,
  49. visibleItemRenderer: (): ReactElement => null,
  50. };
  51. static propTypes = {
  52. // if render in scroll mode, key is required in items
  53. className: PropTypes.string,
  54. collapseFrom: PropTypes.oneOf(strings.BOUNDARY_SET),
  55. direction: PropTypes.oneOf(strings.POSITION_SET),
  56. items: PropTypes.array,
  57. minVisibleItems: PropTypes.number,
  58. onIntersect: PropTypes.func,
  59. onOverflow: PropTypes.func,
  60. overflowRenderer: PropTypes.func,
  61. renderMode: PropTypes.oneOf(strings.MODE_SET),
  62. style: PropTypes.object,
  63. threshold: PropTypes.number,
  64. visibleItemRenderer: PropTypes.func,
  65. wrapperClassName: PropTypes.string,
  66. wrapperStyle: PropTypes.object,
  67. };
  68. constructor(props: OverflowListProps) {
  69. super(props);
  70. this.state = {
  71. direction: OverflowDirection.GROW,
  72. lastOverflowCount: 0,
  73. overflow: [],
  74. visible: props.items,
  75. visibleState: new Map(),
  76. };
  77. this.foundation = new OverflowListFoundation(this.adapter);
  78. this.previousWidths = new Map();
  79. this.itemRefs = {};
  80. this.itemSizeMap = new Map();
  81. }
  82. static getDerivedStateFromProps(props: OverflowListProps, prevState: OverflowListState): OverflowListState {
  83. const { prevProps } = prevState;
  84. const newState: OverflowListState = {};
  85. newState.prevProps = props;
  86. const needUpdate = (name: string): boolean => {
  87. return (!prevProps && name in props) || (prevProps && !isEqual(prevProps[name], props[name]));
  88. };
  89. if (needUpdate('items') || needUpdate('style')) {
  90. // reset visible state if the above props change.
  91. newState.direction = OverflowDirection.GROW;
  92. newState.lastOverflowCount = 0;
  93. newState.overflow = [];
  94. newState.visible = props.items;
  95. }
  96. return newState;
  97. }
  98. get adapter(): OverflowListAdapter {
  99. return {
  100. ...super.adapter,
  101. updateVisibleState: (visibleState): void => {
  102. this.setState({ visibleState });
  103. },
  104. updateStates: (states): void => {
  105. this.setState({ ...states });
  106. },
  107. notifyIntersect: (res): void => {
  108. this.props.onIntersect && this.props.onIntersect(res);
  109. }
  110. };
  111. }
  112. itemRefs: Record<string, any>;
  113. scroller: HTMLDivElement = null;
  114. spacer: HTMLDivElement = null;
  115. previousWidths: Map<Element, number>;
  116. itemSizeMap: Map<string, any>;
  117. isScrollMode = (): boolean => {
  118. const { renderMode } = this.props;
  119. return renderMode === RenderMode.SCROLL;
  120. };
  121. componentDidMount(): void {
  122. this.repartition(false);
  123. }
  124. shouldComponentUpdate(_nextProps: OverflowListProps, nextState: OverflowListState): boolean {
  125. // We want this component to always re-render, even when props haven't changed, so that
  126. // changes in the renderers' behavior can be reflected.
  127. // The following statement prevents re-rendering only in the case where the state changes
  128. // identity (i.e. setState was called), but the state is still the same when
  129. // shallow-compared to the previous state.
  130. const currState = omit(this.state, 'prevProps');
  131. const comingState = omit(nextState, 'prevProps');
  132. return !(currState !== comingState && isEqual(currState, comingState));
  133. }
  134. componentDidUpdate(prevProps: OverflowListProps, prevState: OverflowListState): void {
  135. if (!isEqual(prevProps.items, this.props.items)) {
  136. this.itemRefs = {};
  137. }
  138. if (!isEqual(omit(prevState, 'prevProps'), omit(this.state, 'prevProps'))) {
  139. this.repartition(false);
  140. }
  141. const { direction, overflow, lastOverflowCount } = this.state;
  142. if (
  143. // if a resize operation has just completed (transition to NONE)
  144. direction === OverflowDirection.NONE &&
  145. direction !== prevState.direction &&
  146. overflow.length !== lastOverflowCount
  147. ) {
  148. this.props.onOverflow && this.props.onOverflow(overflow);
  149. }
  150. }
  151. resize = (entries: Array<ResizeEntry> = []): void => {
  152. // if any parent is growing, assume we have more room than before
  153. const growing = entries.some(entry => {
  154. const previousWidth = this.previousWidths.get(entry.target) || 0;
  155. return entry.contentRect.width > previousWidth;
  156. });
  157. this.repartition(growing);
  158. entries.forEach(entry => this.previousWidths.set(entry.target, entry.contentRect.width));
  159. };
  160. repartition = (growing: boolean): void => {
  161. // if not mounted or scroll mode, we do not
  162. if (isNull(this.spacer) || isUndefined(this.spacer) || this.isScrollMode()) {
  163. return;
  164. }
  165. // spacer has flex-shrink and width 1px so if it's much smaller then we know to shrink
  166. const state = growing ?
  167. OverflowDirection.GROW :
  168. this.spacer.getBoundingClientRect().width < 0.9 ? OverflowDirection.SHRINK : OverflowDirection.NONE;
  169. this.foundation.handlePartition(state);
  170. };
  171. reintersect = (entries: Array<IntersectionObserverEntry>): void => {
  172. this.foundation.handleIntersect(entries);
  173. };
  174. mergeRef = (ref: RefCallback<any> | MutableRefObject<any> | null, node: Element, key: Key): void => {
  175. this.itemRefs[key] = node;
  176. if (typeof ref === 'function') {
  177. ref(node);
  178. } else if (typeof ref === 'object' && ref && 'current' in ref) {
  179. ref.current = node;
  180. }
  181. };
  182. renderOverflow = (): ReactNode | ReactNode[] => {
  183. const overflow = this.foundation.getOverflowItem();
  184. return this.props.overflowRenderer(overflow);
  185. };
  186. renderItemList = () => {
  187. const { className, wrapperClassName, wrapperStyle, style, visibleItemRenderer, renderMode, collapseFrom } = this.props;
  188. const { visible } = this.state;
  189. const overflow = this.renderOverflow();
  190. const inner =
  191. renderMode === RenderMode.SCROLL ?
  192. [
  193. overflow[0],
  194. <div
  195. className={cls(wrapperClassName, `${prefixCls}-scroll-wrapper`)}
  196. ref={(ref): void => {
  197. this.scroller = ref;
  198. }}
  199. style={{ ...wrapperStyle }}
  200. key={`${prefixCls}-scroll-wrapper`}
  201. >
  202. {visible.map(visibleItemRenderer).map((item: ReactElement, ind) => {
  203. const { forwardRef, key } = item as any;
  204. return React.cloneElement(item, {
  205. ref: (node: any) => this.mergeRef(forwardRef, node, key),
  206. 'data-scrollkey': `${key}`,
  207. key,
  208. });
  209. })}
  210. </div>,
  211. overflow[1],
  212. ] :
  213. [
  214. collapseFrom === Boundary.START ? overflow : null,
  215. visible.map(visibleItemRenderer),
  216. collapseFrom === Boundary.END ? overflow : null,
  217. <div className={`${prefixCls}-spacer`} ref={ref => (this.spacer = ref)} key={`${prefixCls}-spacer`} />,
  218. ];
  219. const list = React.createElement(
  220. 'div',
  221. {
  222. className: cls(`${prefixCls}`, className),
  223. style,
  224. },
  225. ...inner
  226. );
  227. return list;
  228. };
  229. render(): ReactNode {
  230. const list = this.renderItemList();
  231. const { renderMode } = this.props;
  232. if (renderMode === RenderMode.SCROLL) {
  233. return (
  234. <IntersectionObserver
  235. onIntersect={this.reintersect}
  236. root={this.scroller}
  237. threshold={this.props.threshold}
  238. items={this.itemRefs}
  239. >
  240. {list}
  241. </IntersectionObserver>
  242. );
  243. }
  244. return <ResizeObserver onResize={this.resize}>{list}</ResizeObserver>;
  245. }
  246. }
  247. export default OverflowList;