index.tsx 15 KB

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