index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  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, isFunction, get } from 'lodash';
  7. import { cssClasses, strings, numbers } 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 type { 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. itemKey?: Key | ((item: OverflowItem) => Key)
  33. }
  34. export interface OverflowListState {
  35. direction?: typeof OverflowDirection.GROW;
  36. lastOverflowCount?: number;
  37. overflow?: Array<OverflowItem>;
  38. visible?: Array<OverflowItem>;
  39. visibleState?: Map<string, boolean>;
  40. prevProps?: OverflowListProps;
  41. itemSizeMap?: Map<Key, number>;
  42. containerWidth?: number;
  43. maxCount?: number;
  44. overflowStatus?: 'calculating' | 'overflowed' | 'normal';
  45. pivot?: number;
  46. overflowWidth?: number
  47. }
  48. // reference to https://github.com/palantir/blueprint/blob/1aa71605/packages/core/src/components/overflow-list/overflowList.tsx#L34
  49. class OverflowList extends BaseComponent<OverflowListProps, OverflowListState> {
  50. static defaultProps = {
  51. collapseFrom: 'end',
  52. minVisibleItems: 0,
  53. overflowRenderer: (): ReactElement => null,
  54. renderMode: 'collapse',
  55. threshold: 0.75,
  56. visibleItemRenderer: (): ReactElement => null,
  57. onOverflow: () => null,
  58. };
  59. static propTypes = {
  60. // if render in scroll mode, key is required in items
  61. className: PropTypes.string,
  62. collapseFrom: PropTypes.oneOf(strings.BOUNDARY_SET),
  63. direction: PropTypes.oneOf(strings.POSITION_SET),
  64. items: PropTypes.array,
  65. minVisibleItems: PropTypes.number,
  66. onIntersect: PropTypes.func,
  67. onOverflow: PropTypes.func,
  68. overflowRenderer: PropTypes.func,
  69. renderMode: PropTypes.oneOf(strings.MODE_SET),
  70. style: PropTypes.object,
  71. threshold: PropTypes.number,
  72. visibleItemRenderer: PropTypes.func,
  73. wrapperClassName: PropTypes.string,
  74. wrapperStyle: PropTypes.object,
  75. };
  76. constructor(props: OverflowListProps) {
  77. super(props);
  78. this.state = {
  79. direction: OverflowDirection.GROW,
  80. lastOverflowCount: 0,
  81. overflow: [],
  82. visible: [],
  83. containerWidth: 0,
  84. visibleState: new Map(),
  85. itemSizeMap: new Map(),
  86. overflowStatus: "calculating",
  87. pivot: 0,
  88. overflowWidth: 0,
  89. maxCount: 0,
  90. };
  91. this.foundation = new OverflowListFoundation(this.adapter);
  92. this.previousWidths = new Map();
  93. this.itemRefs = {};
  94. this.itemSizeMap = new Map();
  95. }
  96. static getDerivedStateFromProps(props: OverflowListProps, prevState: OverflowListState): OverflowListState {
  97. const { prevProps } = prevState;
  98. const newState: OverflowListState = {};
  99. newState.prevProps = props;
  100. const needUpdate = (name: string): boolean => {
  101. return (!prevProps && name in props) || (prevProps && !isEqual(prevProps[name], props[name]));
  102. };
  103. if (needUpdate('items') || needUpdate('style')) {
  104. // reset visible state if the above props change.
  105. newState.direction = OverflowDirection.GROW;
  106. newState.lastOverflowCount = 0;
  107. if (props.renderMode === RenderMode.SCROLL) {
  108. newState.visible = props.items;
  109. newState.overflow = [];
  110. } else {
  111. newState.visible = [];
  112. newState.overflow = [];
  113. }
  114. newState.pivot = 0;
  115. newState.maxCount = 0;
  116. newState.overflowStatus = "calculating";
  117. }
  118. return newState;
  119. }
  120. get adapter(): OverflowListAdapter {
  121. return {
  122. ...super.adapter,
  123. updateVisibleState: (visibleState): void => {
  124. this.setState({ visibleState });
  125. },
  126. updateStates: (states): void => {
  127. this.setState({ ...states });
  128. },
  129. notifyIntersect: (res): void => {
  130. this.props.onIntersect && this.props.onIntersect(res);
  131. },
  132. getItemSizeMap: () => this.itemSizeMap
  133. };
  134. }
  135. itemRefs: Record<string, any>;
  136. scroller: HTMLDivElement = null;
  137. spacer: HTMLDivElement = null;
  138. previousWidths: Map<Element, number>;
  139. itemSizeMap: Map<string, number>;
  140. isScrollMode = (): boolean => {
  141. const { renderMode } = this.props;
  142. return renderMode === RenderMode.SCROLL;
  143. };
  144. componentDidUpdate(prevProps: OverflowListProps, prevState: OverflowListState): void {
  145. if (!isEqual(prevProps.items, this.props.items)) {
  146. this.itemRefs = {};
  147. }
  148. const { overflow, containerWidth, visible, overflowStatus } = this.state;
  149. if (this.isScrollMode() || overflowStatus !== "calculating") {
  150. return;
  151. }
  152. if (visible.length === 0 && overflow.length === 0 && this.props.items.length !== 0) {
  153. // 推测container最多能渲染的数量
  154. // Figure out the maximum number of items in this container
  155. const maxCount = Math.min(this.props.items.length, Math.floor(containerWidth / numbers.MINIMUM_HTML_ELEMENT_WIDTH));
  156. // 如果collapseFrom是start, 第一次用来计算容量时,倒转列表顺序渲染
  157. // If collapseFrom === start, render item from end to start. Figuring out how many items in the end could fit in container.
  158. const isCollapseFromStart = this.props.collapseFrom === Boundary.START;
  159. const visible = isCollapseFromStart ? this.foundation.getReversedItems().slice(0, maxCount) : this.props.items.slice(0, maxCount);
  160. const overflow = isCollapseFromStart ? this.foundation.getReversedItems().slice(maxCount) : this.props.items.slice(maxCount);
  161. this.setState({
  162. overflowStatus: 'calculating',
  163. visible,
  164. overflow,
  165. maxCount: maxCount,
  166. });
  167. this.itemSizeMap.clear();
  168. } else {
  169. this.foundation.handleCollapseOverflow();
  170. }
  171. }
  172. resize = (entries: Array<ResizeEntry> = []): void => {
  173. const containerWidth = entries[0]?.target.clientWidth;
  174. this.setState({
  175. containerWidth,
  176. overflowStatus: 'calculating',
  177. });
  178. };
  179. reintersect = (entries: Array<IntersectionObserverEntry>): void => {
  180. this.foundation.handleIntersect(entries);
  181. };
  182. mergeRef = (ref: RefCallback<any> | MutableRefObject<any> | null, node: Element, key: Key): void => {
  183. this.itemRefs[key] = node;
  184. if (typeof ref === 'function') {
  185. ref(node);
  186. } else if (typeof ref === 'object' && ref && 'current' in ref) {
  187. ref.current = node;
  188. }
  189. };
  190. renderOverflow = (): ReactNode | ReactNode[] => {
  191. const overflow = this.foundation.getOverflowItem();
  192. return this.props.overflowRenderer(overflow);
  193. };
  194. getItemKey = (item, defalutKey?: Key) => {
  195. const { itemKey } = this.props;
  196. if (isFunction(itemKey)) {
  197. return itemKey(item);
  198. }
  199. return get(item, itemKey || 'key', defalutKey);
  200. }
  201. renderItemList = () => {
  202. const { className, wrapperClassName, wrapperStyle, style, visibleItemRenderer, renderMode, collapseFrom } = this.props;
  203. const { visible, overflowStatus } = this.state;
  204. let overflow = this.renderOverflow();
  205. if (!this.isScrollMode()) {
  206. if (Array.isArray(overflow)) {
  207. overflow = (
  208. <>
  209. {overflow}
  210. </>
  211. );
  212. }
  213. if (React.isValidElement(overflow)) {
  214. const child = React.cloneElement(overflow);
  215. overflow = (<ResizeObserver
  216. onResize={([entry]) => {
  217. this.setState({
  218. overflowWidth: entry.target.clientWidth,
  219. overflowStatus: 'calculating'
  220. });
  221. }}
  222. >
  223. <div className={`${prefixCls}-overflow`}>
  224. {child}
  225. </div>
  226. </ResizeObserver>);
  227. }
  228. }
  229. const inner =
  230. renderMode === RenderMode.SCROLL ?
  231. [
  232. overflow[0],
  233. <div
  234. className={cls(wrapperClassName, `${prefixCls}-scroll-wrapper`)}
  235. ref={(ref): void => {
  236. this.scroller = ref;
  237. }}
  238. style={{ ...wrapperStyle }}
  239. key={`${prefixCls}-scroll-wrapper`}
  240. >
  241. {visible.map(visibleItemRenderer).map((item: ReactElement) => {
  242. const { forwardRef, key } = item as any;
  243. return React.cloneElement(item, {
  244. ref: (node: any) => this.mergeRef(forwardRef, node, key),
  245. 'data-scrollkey': `${key}`,
  246. key,
  247. });
  248. })}
  249. </div>,
  250. overflow[1],
  251. ] :
  252. [
  253. collapseFrom === Boundary.START ? overflow : null,
  254. visible.map((item, idx) => {
  255. const { key } = item;
  256. const element = visibleItemRenderer(item, idx);
  257. const child = React.cloneElement(element);
  258. return (
  259. <ResizeObserver
  260. key={key}
  261. onResize={([entry]) => this.onItemResize(entry, item, idx)}
  262. >
  263. {/* 用div包起来,可以直接在resize回调中拿到宽度,不用通过获取元素的padding, margin, border-width求和计算宽度*/}
  264. {/* This div wrap can get width directly rather than do the math of padding, margin, border-width*/}
  265. <div key={key} className={`${prefixCls}-item`}>
  266. {child}
  267. </div>
  268. </ResizeObserver>);
  269. }),
  270. collapseFrom === Boundary.END ? overflow : null,
  271. ];
  272. const list = React.createElement(
  273. 'div',
  274. {
  275. className: cls(`${prefixCls}`, className),
  276. style: {
  277. ...style,
  278. ...(renderMode === RenderMode.COLLAPSE ? {
  279. maxWidth: '100%',
  280. visibility: overflowStatus === "calculating" ? "hidden" : "visible",
  281. } : null)
  282. },
  283. },
  284. ...inner
  285. );
  286. return list;
  287. };
  288. onItemResize = (entry: ResizeEntry, item: OverflowItem, idx: number) => {
  289. const key = this.getItemKey(item, idx);
  290. const width = this.itemSizeMap.get(key);
  291. if (!width) {
  292. this.itemSizeMap.set(key, entry.target.clientWidth);
  293. } else if (width !== entry.target.clientWidth) {
  294. // 某个item发生resize后,重新计算
  295. this.itemSizeMap.set(key, entry.target.clientWidth);
  296. this.setState({
  297. overflowStatus: 'calculating'
  298. });
  299. }
  300. const { maxCount } = this.state;
  301. // 已经按照最大值maxCount渲染完毕,触发真正的渲染。(-1 是overflow部分会占1)
  302. // Already rendered maxCount items, trigger the real rendering. (-1 for the overflow part)
  303. if (this.itemSizeMap.size === maxCount - 1) {
  304. this.setState({
  305. overflowStatus: 'calculating'
  306. });
  307. }
  308. }
  309. render(): ReactNode {
  310. const list = this.renderItemList();
  311. const { renderMode } = this.props;
  312. if (renderMode === RenderMode.SCROLL) {
  313. return (
  314. <IntersectionObserver
  315. onIntersect={this.reintersect}
  316. root={this.scroller}
  317. threshold={this.props.threshold}
  318. items={this.itemRefs}
  319. >
  320. {list}
  321. </IntersectionObserver>
  322. );
  323. }
  324. return <ResizeObserver onResize={this.resize}>{list}</ResizeObserver>;
  325. }
  326. }
  327. export default OverflowList;