index.tsx 14 KB

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