index.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. import React, { ReactNode } from 'react';
  2. import cls from 'classnames';
  3. import PropTypes from 'prop-types';
  4. import { cssClasses, strings } from '@douyinfe/semi-foundation/anchor/constants';
  5. import AnchorFoundation, { AnchorAdapter } from '@douyinfe/semi-foundation/anchor/foundation';
  6. import BaseComponent from '../_base/baseComponent';
  7. import Link from './link';
  8. import AnchorContext from './anchor-context';
  9. import '@douyinfe/semi-foundation/anchor/anchor.scss';
  10. import { noop, debounce, throttle } from 'lodash';
  11. import getUuid from '@douyinfe/semi-foundation/utils/uuid';
  12. import { ArrayElement } from '../_base/base';
  13. import ConfigContext, { ContextValue } from '../configProvider/context';
  14. import { ShowTooltip } from '../typography/interface';
  15. const prefixCls = cssClasses.PREFIX;
  16. export type { LinkProps } from './link';
  17. export interface AnchorProps {
  18. autoCollapse?: boolean;
  19. className?: string;
  20. children?: ReactNode;
  21. defaultAnchor?: string;
  22. getContainer?: () => HTMLElement | Window;
  23. maxHeight?: string | number;
  24. maxWidth?: string | number;
  25. offsetTop?: number;
  26. position?: ArrayElement<typeof strings.POSITION_SET>;
  27. railTheme?: ArrayElement<typeof strings.SLIDE_COLOR>;
  28. scrollMotion?: boolean;
  29. showTooltip?: boolean | ShowTooltip;
  30. size?: ArrayElement<typeof strings.SIZE>;
  31. style?: React.CSSProperties;
  32. targetOffset?: number;
  33. onChange?: (currentLink: string, previousLink: string) => void;
  34. onClick?: (e: React.MouseEvent<HTMLElement>, currentLink: string) => void;
  35. 'aria-label'?: React.AriaAttributes['aria-label']
  36. }
  37. export interface AnchorState {
  38. activeLink: string;
  39. links: string[];
  40. clickLink: boolean;
  41. scrollHeight: string;
  42. slideBarTop: string
  43. }
  44. class Anchor extends BaseComponent<AnchorProps, AnchorState> {
  45. static contextType = ConfigContext;
  46. static Link = Link;
  47. static PropTypes = {
  48. size: PropTypes.oneOf(strings.SIZE),
  49. railTheme: PropTypes.oneOf(strings.SLIDE_COLOR),
  50. className: PropTypes.string,
  51. style: PropTypes.object,
  52. scrollMotion: PropTypes.bool,
  53. autoCollapse: PropTypes.bool,
  54. offsetTop: PropTypes.number,
  55. targetOffset: PropTypes.number,
  56. showTooltip: PropTypes.bool,
  57. position: PropTypes.oneOf(strings.POSITION_SET),
  58. maxWidth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  59. maxHeight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  60. getContainer: PropTypes.func,
  61. onChange: PropTypes.func,
  62. onClick: PropTypes.func,
  63. defaultAnchor: PropTypes.string,
  64. 'aria-label': PropTypes.string,
  65. };
  66. static defaultProps = {
  67. size: 'default',
  68. railTheme: 'primary',
  69. className: '',
  70. scrollMotion: false,
  71. autoCollapse: false,
  72. offsetTop: 0,
  73. targetOffset: 0,
  74. showTooltip: false,
  75. maxWidth: strings.MAX_WIDTH,
  76. maxHeight: strings.MAX_HEIGHT,
  77. getContainer: noop,
  78. onChange: noop,
  79. onClick: noop,
  80. defaultAnchor: '',
  81. };
  82. foundation: AnchorFoundation;
  83. anchorID: string;
  84. scrollContainer: HTMLElement | Window;
  85. childMap: Record<string, Set<string>>;
  86. handler: () => void;
  87. clickHandler: () => void;
  88. context: ContextValue;
  89. constructor(props: AnchorProps) {
  90. super(props);
  91. this.state = {
  92. activeLink: '',
  93. links: [],
  94. clickLink: false,
  95. scrollHeight: '100%',
  96. slideBarTop: '0',
  97. };
  98. this.foundation = new AnchorFoundation(this.adapter);
  99. this.childMap = {};
  100. }
  101. get adapter(): AnchorAdapter<AnchorProps, AnchorState> {
  102. return {
  103. ...super.adapter,
  104. addLink: value => {
  105. this.setState(prevState => ({ links: [...prevState.links, value] }));
  106. },
  107. removeLink: link => {
  108. this.setState(prevState => {
  109. const links = prevState.links.slice();
  110. const index = links.indexOf(link);
  111. if (index !== -1) {
  112. links.splice(index, 1);
  113. return { links };
  114. }
  115. return undefined;
  116. });
  117. },
  118. setChildMap: value => {
  119. this.childMap = value;
  120. },
  121. setScrollHeight: height => {
  122. this.setState({ scrollHeight: height });
  123. },
  124. setSlideBarTop: height => {
  125. this.setState({ slideBarTop: `${height}px` });
  126. },
  127. setClickLink: value => {
  128. this.setState({ clickLink: value });
  129. },
  130. setActiveLink: (link, cb) => {
  131. this.setState({ activeLink: link }, () => {
  132. cb();
  133. });
  134. },
  135. setClickLinkWithCallBack: (value, link, cb) => {
  136. this.setState({ clickLink: value }, () => {
  137. cb(link);
  138. });
  139. },
  140. getContainer: () => {
  141. const { getContainer } = this.props;
  142. const container = getContainer();
  143. return container ? container : window;
  144. },
  145. getContainerBoundingTop: () => {
  146. const container = this.adapter.getContainer();
  147. if ('getBoundingClientRect' in container) {
  148. return container.getBoundingClientRect().top;
  149. }
  150. return 0;
  151. },
  152. getLinksBoundingTop: () => {
  153. const { links } = this.state;
  154. const { offsetTop } = this.props;
  155. const containerTop = this.adapter.getContainerBoundingTop();
  156. const elTop = links.map(link => {
  157. let node = null;
  158. try {
  159. // Get links from containers
  160. node = document.querySelector(link);
  161. } catch (e) {}
  162. return (node && node.getBoundingClientRect().top - containerTop - offsetTop) || -Infinity;
  163. });
  164. return elTop;
  165. },
  166. getAnchorNode: selector => {
  167. const selectors = `#${this.anchorID} ${selector}`;
  168. return document.querySelector(selectors);
  169. },
  170. getContentNode: selector => document.querySelector(selector),
  171. notifyChange: (currentLink, previousLink) => this.props.onChange(currentLink, previousLink),
  172. notifyClick: (e, link) => this.props.onClick(e, link),
  173. canSmoothScroll: () => 'scrollBehavior' in document.body.style,
  174. };
  175. }
  176. addLink = (link: string) => {
  177. this.foundation.addLink(link);
  178. };
  179. removeLink = (link: string) => {
  180. this.foundation.removeLink(link);
  181. };
  182. handleScroll = () => {
  183. this.foundation.handleScroll();
  184. };
  185. handleClick = (e: React.MouseEvent<HTMLElement>, link: string) => {
  186. this.foundation.handleClick(e, link);
  187. };
  188. // Set click to false after scrolling
  189. handleClickLink = () => {
  190. this.foundation.handleClickLink();
  191. };
  192. setChildMap = () => {
  193. this.foundation.setChildMap();
  194. };
  195. setScrollHeight = () => {
  196. this.foundation.setScrollHeight();
  197. };
  198. updateScrollHeight = (prevState: AnchorState, state: AnchorState) => {
  199. this.foundation.updateScrollHeight(prevState, state);
  200. };
  201. updateChildMap = (prevState: AnchorState, state: AnchorState) => {
  202. this.foundation.updateChildMap(prevState, state);
  203. };
  204. renderChildren = () => {
  205. const loop = (children, level = 1) => {
  206. return React.Children.map(children, child => {
  207. if (React.isValidElement(child)) {
  208. const childProps = {
  209. direction: this.context.direction,
  210. level,
  211. children: [],
  212. };
  213. const { children } = child.props as any;
  214. const hasChildren = children && React.Children.count(children) > 0;
  215. if (hasChildren) {
  216. childProps.children = loop(children, level + 1);
  217. }
  218. return React.cloneElement(child, childProps);
  219. }
  220. return null;
  221. });
  222. };
  223. return loop(this.props.children);
  224. };
  225. componentDidMount() {
  226. const { defaultAnchor = '' } = this.props;
  227. this.anchorID = getUuid('semi-anchor').replace('.', '');
  228. this.scrollContainer = this.adapter.getContainer();
  229. this.handler = throttle(this.handleScroll, 100);
  230. this.clickHandler = debounce(this.handleClickLink, 100);
  231. this.scrollContainer.addEventListener('scroll', this.handler);
  232. this.scrollContainer.addEventListener('scroll', this.clickHandler);
  233. this.setScrollHeight();
  234. this.setChildMap();
  235. Boolean(defaultAnchor) && this.foundation.handleClick(null, defaultAnchor, false);
  236. }
  237. componentDidUpdate(prevProps: AnchorProps, prevState: AnchorState) {
  238. this.updateScrollHeight(prevState, this.state);
  239. this.updateChildMap(prevState, this.state);
  240. }
  241. componentWillUnmount() {
  242. this.scrollContainer.removeEventListener('scroll', this.handler);
  243. this.scrollContainer.removeEventListener('scroll', this.clickHandler);
  244. }
  245. render() {
  246. const {
  247. size,
  248. railTheme,
  249. style,
  250. className,
  251. children,
  252. maxWidth,
  253. maxHeight,
  254. showTooltip,
  255. position,
  256. autoCollapse,
  257. } = this.props;
  258. const ariaLabel = this.props['aria-label'];
  259. const { activeLink, scrollHeight, slideBarTop } = this.state;
  260. const wrapperCls = cls(prefixCls, className, {
  261. [`${prefixCls}-size-${size}`]: size,
  262. });
  263. const slideCls = cls(`${prefixCls}-slide`, `${prefixCls}-slide-${railTheme}`);
  264. const slideBarCls = cls(`${prefixCls}-slide-bar`, {
  265. [`${prefixCls}-slide-bar-${size}`]: size,
  266. [`${prefixCls}-slide-bar-${railTheme}`]: railTheme,
  267. [`${prefixCls}-slide-bar-active`]: activeLink,
  268. });
  269. const anchorWrapper = `${prefixCls}-link-wrapper`;
  270. const wrapperStyle = {
  271. ...style,
  272. maxWidth,
  273. maxHeight,
  274. };
  275. return (
  276. <AnchorContext.Provider
  277. value={{
  278. activeLink,
  279. showTooltip,
  280. position,
  281. childMap: this.childMap,
  282. autoCollapse,
  283. size,
  284. onClick: (e, link) => this.handleClick(e, link),
  285. addLink: this.addLink,
  286. removeLink: this.removeLink,
  287. }}
  288. >
  289. <div
  290. role="navigation"
  291. aria-label={ariaLabel || 'Side navigation'}
  292. className={wrapperCls}
  293. style={wrapperStyle}
  294. id={this.anchorID}
  295. {...this.getDataAttr(this.props)}
  296. >
  297. <div aria-hidden className={slideCls} style={{ height: scrollHeight }}>
  298. <span className={slideBarCls} style={{ top: slideBarTop }} />
  299. </div>
  300. <div className={anchorWrapper} role="list">
  301. {this.renderChildren()}
  302. </div>
  303. </div>
  304. </AnchorContext.Provider>
  305. );
  306. }
  307. }
  308. export default Anchor;