index.tsx 10 KB

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