index.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. import React, { CSSProperties, ReactNode, MutableRefObject, RefCallback, Key, ReactElement } from 'react';
  2. import cls from 'classnames';
  3. import BaseComponent from '../_base/baseComponent';
  4. import PropTypes from 'prop-types';
  5. import { isEqual, isFunction, get } from 'lodash';
  6. import { cssClasses, strings, numbers } from '@douyinfe/semi-foundation/overflowList/constants';
  7. import ResizeObserver, { ResizeEntry } from '../resizeObserver';
  8. import IntersectionObserver from './intersectionObserver';
  9. import OverflowListFoundation, { OverflowListAdapter } from '@douyinfe/semi-foundation/overflowList/foundation';
  10. import '@douyinfe/semi-foundation/overflowList/overflowList.scss';
  11. import { cloneDeep } from '../_utils';
  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: -1,
  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. newState.maxCount = 0;
  108. if (props.renderMode === RenderMode.SCROLL) {
  109. newState.visible = props.items;
  110. newState.overflow = [];
  111. } else {
  112. let maxCount = props.items.length;
  113. if (Math.floor(prevState.containerWidth / numbers.MINIMUM_HTML_ELEMENT_WIDTH) !== 0) {
  114. maxCount = Math.min(maxCount, Math.floor(prevState.containerWidth / numbers.MINIMUM_HTML_ELEMENT_WIDTH));
  115. }
  116. const isCollapseFromStart = props.collapseFrom === Boundary.START;
  117. const visible = isCollapseFromStart ? cloneDeep(props.items).reverse().slice(0, maxCount) : props.items.slice(0, maxCount);
  118. const overflow = isCollapseFromStart ? cloneDeep(props.items).reverse().slice(maxCount) : props.items.slice(maxCount);
  119. newState.visible = visible;
  120. newState.overflow = overflow;
  121. newState.maxCount = maxCount;
  122. }
  123. newState.pivot = -1;
  124. newState.overflowStatus = "calculating";
  125. }
  126. return newState;
  127. }
  128. get adapter(): OverflowListAdapter {
  129. return {
  130. ...super.adapter,
  131. updateVisibleState: (visibleState): void => {
  132. this.setState({ visibleState });
  133. },
  134. updateStates: (states): void => {
  135. this.setState({ ...states });
  136. },
  137. notifyIntersect: (res): void => {
  138. this.props.onIntersect && this.props.onIntersect(res);
  139. },
  140. getItemSizeMap: () => this.itemSizeMap
  141. };
  142. }
  143. itemRefs: Record<string, any>;
  144. scroller: HTMLDivElement = null;
  145. spacer: HTMLDivElement = null;
  146. previousWidths: Map<Element, number>;
  147. itemSizeMap: Map<string, number>;
  148. isScrollMode = (): boolean => {
  149. const { renderMode } = this.props;
  150. return renderMode === RenderMode.SCROLL;
  151. };
  152. componentDidUpdate(prevProps: OverflowListProps, prevState: OverflowListState): void {
  153. const prevItemsKeys = prevProps.items.map((item) =>
  154. item.key
  155. );
  156. const nowItemsKeys = this.props.items.map((item) =>
  157. item.key
  158. );
  159. // Determine whether to update by comparing key values
  160. if (!isEqual(prevItemsKeys, nowItemsKeys)) {
  161. this.itemRefs = {};
  162. this.setState({ visibleState: new Map() });
  163. }
  164. const { overflow, containerWidth, visible, overflowStatus } = this.state;
  165. if (this.isScrollMode() || overflowStatus !== "calculating") {
  166. return;
  167. }
  168. this.foundation.handleCollapseOverflow();
  169. }
  170. resize = (entries: Array<ResizeEntry> = []): void => {
  171. const containerWidth = entries[0]?.target.clientWidth;
  172. this.setState({
  173. containerWidth,
  174. overflowStatus: 'calculating',
  175. });
  176. };
  177. reintersect = (entries: Array<IntersectionObserverEntry>): void => {
  178. this.foundation.handleIntersect(entries);
  179. };
  180. mergeRef = (ref: RefCallback<any> | MutableRefObject<any> | null, node: Element, key: Key): void => {
  181. this.itemRefs[key] = node;
  182. if (typeof ref === 'function') {
  183. ref(node);
  184. } else if (typeof ref === 'object' && ref && 'current' in ref) {
  185. ref.current = node;
  186. }
  187. };
  188. renderOverflow = (): ReactNode | ReactNode[] => {
  189. const overflow = this.foundation.getOverflowItem();
  190. return this.props.overflowRenderer(overflow);
  191. };
  192. getItemKey = (item, defaultKey?: Key) => {
  193. const { itemKey } = this.props;
  194. if (isFunction(itemKey)) {
  195. return itemKey(item);
  196. }
  197. return get(item, itemKey || 'key', defaultKey);
  198. }
  199. renderItemList = () => {
  200. const { className, wrapperClassName, wrapperStyle, style, visibleItemRenderer, renderMode, collapseFrom } = this.props;
  201. const { visible, overflowStatus } = this.state;
  202. let overflow = this.renderOverflow();
  203. if (!this.isScrollMode()) {
  204. if (Array.isArray(overflow)) {
  205. overflow = (
  206. <>
  207. {overflow}
  208. </>
  209. );
  210. }
  211. if (React.isValidElement(overflow)) {
  212. const child = React.cloneElement(overflow);
  213. overflow = (<ResizeObserver
  214. onResize={([entry]) => {
  215. this.setState({
  216. overflowWidth: entry.target.clientWidth,
  217. overflowStatus: 'calculating'
  218. });
  219. }}
  220. >
  221. <div className={`${prefixCls}-overflow`}>
  222. {child}
  223. </div>
  224. </ResizeObserver>);
  225. }
  226. }
  227. const inner =
  228. renderMode === RenderMode.SCROLL ?
  229. [
  230. overflow[0],
  231. <div
  232. className={cls(wrapperClassName, `${prefixCls}-scroll-wrapper`)}
  233. ref={(ref): void => {
  234. this.scroller = ref;
  235. }}
  236. style={{ ...wrapperStyle }}
  237. key={`${prefixCls}-scroll-wrapper`}
  238. >
  239. {visible.map(visibleItemRenderer).map((item: ReactElement) => {
  240. const { forwardRef, key } = item as any;
  241. return React.cloneElement(item, {
  242. ref: (node: any) => this.mergeRef(forwardRef, node, key),
  243. 'data-scrollkey': `${key}`,
  244. key,
  245. });
  246. })}
  247. </div>,
  248. overflow[1],
  249. ] :
  250. [
  251. collapseFrom === Boundary.START ? overflow : null,
  252. visible.map((item, idx) => {
  253. const { key } = item;
  254. const element = visibleItemRenderer(item, idx);
  255. const child = React.cloneElement(element);
  256. return (
  257. <ResizeObserver
  258. key={key ?? idx}
  259. onResize={([entry]) => this.onItemResize(entry, item, idx)}
  260. >
  261. {/* 用div包起来,可以直接在resize回调中拿到宽度,不用通过获取元素的padding, margin, border-width求和计算宽度*/}
  262. {/* This div wrap can get width directly rather than do the math of padding, margin, border-width*/}
  263. <div key={key ?? idx} className={`${prefixCls}-item`}>
  264. {child}
  265. </div>
  266. </ResizeObserver>);
  267. }),
  268. collapseFrom === Boundary.END ? overflow : null,
  269. ];
  270. const list = React.createElement(
  271. 'div',
  272. {
  273. className: cls(`${prefixCls}`, className),
  274. style: {
  275. ...style,
  276. ...(renderMode === RenderMode.COLLAPSE ? {
  277. maxWidth: '100%',
  278. visibility: overflowStatus === "calculating" ? "hidden" : "visible",
  279. } : null)
  280. },
  281. },
  282. ...inner
  283. );
  284. return list;
  285. };
  286. onItemResize = (entry: ResizeEntry, item: OverflowItem, idx: number) => {
  287. const key = this.getItemKey(item, idx);
  288. const width = this.itemSizeMap.get(key);
  289. if (!width) {
  290. this.itemSizeMap.set(key, entry.target.clientWidth);
  291. } else if (width !== entry.target.clientWidth) {
  292. // 某个item发生resize后,重新计算
  293. this.itemSizeMap.set(key, entry.target.clientWidth);
  294. this.setState({
  295. overflowStatus: 'calculating'
  296. });
  297. }
  298. const { maxCount } = this.state;
  299. // 已经按照最大值maxCount渲染完毕,触发真正的渲染
  300. // Already rendered maxCount items, trigger the real rendering
  301. if (this.itemSizeMap.size === maxCount) {
  302. this.setState({
  303. overflowStatus: 'calculating'
  304. });
  305. }
  306. }
  307. render(): ReactNode {
  308. const list = this.renderItemList();
  309. const { renderMode } = this.props;
  310. if (renderMode === RenderMode.SCROLL) {
  311. return (
  312. <IntersectionObserver
  313. onIntersect={this.reintersect}
  314. root={this.scroller}
  315. threshold={this.props.threshold}
  316. items={this.itemRefs}
  317. >
  318. {list}
  319. </IntersectionObserver>
  320. );
  321. }
  322. return <ResizeObserver onResize={this.resize}>{list}</ResizeObserver>;
  323. }
  324. }
  325. export default OverflowList;