1
0

index.tsx 11 KB

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