index.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. /* eslint-disable max-lines-per-function */
  2. import BaseComponent, { BaseProps } from '../_base/baseComponent';
  3. import React, { Children } from 'react';
  4. import PropTypes from 'prop-types';
  5. import cls from 'classnames';
  6. import { noop, get, isEqual } from 'lodash';
  7. import NavigationFoundation, { NavigationAdapter } from '@douyinfe/semi-foundation/navigation/foundation';
  8. import { strings, cssClasses, numbers } from '@douyinfe/semi-foundation/navigation/constants';
  9. import SubNav, { SubNavProps } from './SubNav';
  10. import Item, { NavItemProps } from './Item';
  11. import Footer, { NavFooterProps } from './Footer';
  12. import Header, { NavHeaderProps } from './Header';
  13. import NavContext from './nav-context';
  14. import LocaleConsumer from '../locale/localeConsumer';
  15. import '@douyinfe/semi-foundation/navigation/navigation.scss';
  16. import { Motion } from '../_base/base';
  17. export type { CollapseButtonProps } from './CollapseButton';
  18. export type { NavFooterProps } from './Footer';
  19. export type { NavHeaderProps } from './Header';
  20. export type { NavItemProps } from './Item';
  21. export type { ToggleIcon, SubNavProps } from './SubNav';
  22. export type Mode = 'vertical' | 'horizontal';
  23. export interface OnSelectedData {
  24. itemKey: React.ReactText;
  25. selectedKeys: React.ReactText[];
  26. selectedItems: (NavItemProps | SubNavProps)[];
  27. domEvent: React.MouseEvent;
  28. isOpen: boolean;
  29. }
  30. export interface SubNavPropsWithItems extends SubNavProps {
  31. items?: (SubNavPropsWithItems | string)[];
  32. }
  33. export interface NavItemPropsWithItems extends NavItemProps {
  34. items?: (NavItemPropsWithItems | string)[];
  35. }
  36. export type NavItems = (string | SubNavPropsWithItems | NavItemPropsWithItems)[];
  37. export interface NavProps extends BaseProps {
  38. bodyStyle?: React.CSSProperties;
  39. children?: React.ReactNode;
  40. defaultIsCollapsed?: boolean;
  41. defaultOpenKeys?: React.ReactText[];
  42. defaultSelectedKeys?: React.ReactText[];
  43. footer?: React.ReactNode | NavFooterProps;
  44. header?: React.ReactNode | NavHeaderProps;
  45. isCollapsed?: boolean;
  46. items?: NavItems;
  47. limitIndent?: boolean;
  48. mode?: Mode;
  49. multiple?: boolean;
  50. openKeys?: React.ReactText[];
  51. prefixCls?: string;
  52. selectedKeys?: React.ReactText[];
  53. subNavCloseDelay?: number;
  54. subNavMotion?: boolean;
  55. subNavOpenDelay?: number;
  56. toggleIconPosition?: string;
  57. tooltipHideDelay?: number;
  58. tooltipShowDelay?: number;
  59. onClick?: (data: { itemKey: React.ReactText; domEvent: MouseEvent; isOpen: boolean }) => void;
  60. onCollapseChange?: (isCollapse: boolean) => void;
  61. onDeselect?: (data?: any) => void;
  62. onOpenChange?: (data: { itemKey: (string | number); openKeys: (string | number)[]; domEvent: MouseEvent; isOpen: boolean }) => void;
  63. onSelect?: (data: OnSelectedData) => void;
  64. }
  65. export interface NavState {
  66. isCollapsed: boolean;
  67. // calc state
  68. openKeys: (string | number)[];
  69. items: any[];
  70. itemKeysMap: { [itemKey: string]: (string | number)[] };
  71. selectedKeys: (string | number)[];
  72. }
  73. function createAddKeysFn(context: Nav, keyName: string | number) {
  74. return function addKeys(...keys: (string | number)[]) {
  75. const handleKeys = new Set(context.state[keyName]);
  76. keys.forEach(key => key && handleKeys.add(key));
  77. context.setState({ [keyName]: Array.from(handleKeys) } as any);
  78. };
  79. }
  80. function createRemoveKeysFn(context: Nav, keyName: string) {
  81. return function removeKeys(...keys: string[]) {
  82. const handleKeys = new Set(context.state[keyName]);
  83. keys.forEach(key => key && handleKeys.delete(key));
  84. context.setState({ [keyName]: Array.from(handleKeys) } as any);
  85. };
  86. }
  87. const { hasOwnProperty } = Object.prototype;
  88. class Nav extends BaseComponent<NavProps, NavState> {
  89. static Sub = SubNav;
  90. static Item = Item;
  91. static Header = Header;
  92. static Footer = Footer;
  93. static propTypes = {
  94. // Initial expanded SubNav navigation key array
  95. defaultOpenKeys: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])),
  96. openKeys: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])),
  97. // Initial selected navigation key array
  98. defaultSelectedKeys: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])),
  99. selectedKeys: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])),
  100. // Navigation type, now supports vertical, horizontal
  101. mode: PropTypes.oneOf([...strings.MODE]),
  102. // Triggered when selecting a navigation item
  103. onSelect: PropTypes.func,
  104. // Triggered when clicking a navigation item
  105. onClick: PropTypes.func,
  106. // SubNav expand/close callback
  107. onOpenChange: PropTypes.func,
  108. // Array of options (nested options can continue)
  109. items: PropTypes.array,
  110. // Is it in the state of being stowed to the sidebar
  111. isCollapsed: PropTypes.bool,
  112. defaultIsCollapsed: PropTypes.bool,
  113. onCollapseChange: PropTypes.func,
  114. multiple: PropTypes.bool,
  115. onDeselect: PropTypes.func,
  116. subNavMotion: PropTypes.oneOfType([PropTypes.bool, PropTypes.object, PropTypes.func]),
  117. subNavCloseDelay: PropTypes.number,
  118. subNavOpenDelay: PropTypes.number,
  119. tooltipShowDelay: PropTypes.number,
  120. tooltipHideDelay: PropTypes.number,
  121. children: PropTypes.node,
  122. style: PropTypes.object,
  123. bodyStyle: PropTypes.object,
  124. className: PropTypes.string,
  125. toggleIconPosition: PropTypes.string,
  126. prefixCls: PropTypes.string,
  127. header: PropTypes.oneOfType([PropTypes.node, PropTypes.object]),
  128. footer: PropTypes.oneOfType([PropTypes.node, PropTypes.object]),
  129. limitIndent: PropTypes.bool
  130. };
  131. static defaultProps = {
  132. subNavCloseDelay: numbers.DEFAULT_SUBNAV_CLOSE_DELAY,
  133. subNavOpenDelay: numbers.DEFAULT_SUBNAV_OPEN_DELAY,
  134. tooltipHideDelay: numbers.DEFAULT_TOOLTIP_HIDE_DELAY,
  135. tooltipShowDelay: numbers.DEFAULT_TOOLTIP_SHOW_DELAY,
  136. onCollapseChange: noop,
  137. onSelect: noop,
  138. onClick: noop,
  139. onOpenChange: noop,
  140. toggleIconPosition: 'right',
  141. limitIndent: true,
  142. prefixCls: cssClasses.PREFIX,
  143. subNavMotion: true,
  144. // isOpen: false,
  145. mode: strings.MODE_VERTICAL,
  146. // defaultOpenKeys: [],
  147. // defaultSelectedKeys: [],
  148. // items: [],
  149. };
  150. itemsChanged: boolean;
  151. constructor(props: NavProps) {
  152. super(props);
  153. this.foundation = new NavigationFoundation(this.adapter);
  154. this.itemsChanged = true;
  155. const { isCollapsed, defaultIsCollapsed, items, children } = props;
  156. const initState = {
  157. isCollapsed: Boolean(this.isControlled('isCollapsed') ? isCollapsed : defaultIsCollapsed),
  158. // calc state
  159. openKeys: [],
  160. items: [],
  161. itemKeysMap: {}, // itemKey to parentKeys
  162. selectedKeys: [],
  163. };
  164. this.state = { ...initState };
  165. if (items && items.length || children) {
  166. const calcState = this.foundation.init('constructor');
  167. this.state = {
  168. ...initState,
  169. ...calcState,
  170. };
  171. }
  172. }
  173. static getDerivedStateFromProps(props: NavProps, state: NavState) {
  174. const willUpdateState: Partial<NavState> = {};
  175. if (hasOwnProperty.call(props, 'isCollapsed') && props.isCollapsed !== state.isCollapsed) {
  176. willUpdateState.isCollapsed = props.isCollapsed;
  177. }
  178. return willUpdateState;
  179. }
  180. componentDidMount() {
  181. // override BaseComponent
  182. }
  183. componentDidUpdate(prevProps: NavProps, prevState: NavState) {
  184. if (prevProps.items !== this.props.items || prevProps.children !== this.props.children) {
  185. this.foundation.init();
  186. } else {
  187. this.foundation.handleItemsChange(false);
  188. const { selectedKeys } = this.state;
  189. if (this.props.selectedKeys && !isEqual(prevProps.selectedKeys, this.props.selectedKeys)) {
  190. this.adapter.updateSelectedKeys(this.props.selectedKeys);
  191. }
  192. if (this.props.openKeys && !isEqual(prevProps.openKeys, this.props.openKeys)) {
  193. this.adapter.updateOpenKeys(this.props.openKeys);
  194. }
  195. if (!isEqual(selectedKeys, prevState.selectedKeys)) {
  196. const parentSelectKeys = this.foundation.selectLevelZeroParentKeys(null, ...selectedKeys);
  197. this.adapter.addSelectedKeys(...parentSelectKeys);
  198. }
  199. }
  200. }
  201. get adapter(): NavigationAdapter<NavProps, NavState> {
  202. return {
  203. ...super.adapter,
  204. notifySelect: (...args) => this.props.onSelect(...args),
  205. notifyOpenChange: (...args) => this.props.onOpenChange(...args),
  206. setIsCollapsed: isCollapsed => this.setState({ isCollapsed }),
  207. notifyCollapseChange: (...args) => this.props.onCollapseChange(...args),
  208. updateItems: items => this.setState({ items: [...items] }),
  209. setItemKeysMap: itemKeysMap => this.setState({ itemKeysMap: { ...itemKeysMap } }),
  210. addSelectedKeys: createAddKeysFn(this, 'selectedKeys'),
  211. removeSelectedKeys: createRemoveKeysFn(this, 'selectedKeys'),
  212. updateSelectedKeys: selectedKeys => this.setState({ selectedKeys: [...selectedKeys] }),
  213. updateOpenKeys: openKeys => this.setState({ openKeys: [...openKeys] }),
  214. addOpenKeys: createAddKeysFn(this, 'openKeys'),
  215. removeOpenKeys: createRemoveKeysFn(this, 'openKeys'),
  216. setItemsChanged: isChanged => {
  217. this.itemsChanged = isChanged;
  218. },
  219. };
  220. }
  221. /**
  222. * Render navigation items recursively
  223. *
  224. * @param {NavItem[]} items
  225. * @returns {JSX.Element}
  226. */
  227. renderItems(items: (SubNavPropsWithItems | NavItemPropsWithItems)[] = [], level = 0) {
  228. const finalDom = (
  229. <>
  230. {items.map((item, idx) => {
  231. if (Array.isArray(item.items) && item.items.length) {
  232. return (
  233. <SubNav key={item.itemKey || String(level) + idx} {...item as SubNavPropsWithItems} level={level}>
  234. {this.renderItems(item.items as (SubNavPropsWithItems | NavItemPropsWithItems)[], level + 1)}
  235. </SubNav>
  236. );
  237. } else {
  238. return <Item key={item.itemKey || String(level) + idx} {...item as NavItemPropsWithItems} level={level} />;
  239. }
  240. })}
  241. </>
  242. );
  243. return finalDom;
  244. }
  245. onCollapseChange = () => {
  246. this.foundation.handleCollapseChange();
  247. };
  248. render() {
  249. const {
  250. children: originChildren,
  251. mode,
  252. onOpenChange,
  253. onSelect,
  254. onClick,
  255. style,
  256. className,
  257. subNavCloseDelay,
  258. subNavOpenDelay,
  259. subNavMotion,
  260. tooltipShowDelay,
  261. tooltipHideDelay,
  262. prefixCls,
  263. bodyStyle,
  264. footer,
  265. header,
  266. toggleIconPosition,
  267. limitIndent
  268. } = this.props;
  269. const { selectedKeys, openKeys, items, isCollapsed } = this.state;
  270. const {
  271. updateOpenKeys,
  272. addOpenKeys,
  273. removeOpenKeys,
  274. updateSelectedKeys,
  275. addSelectedKeys,
  276. removeSelectedKeys,
  277. } = this.adapter;
  278. const finalStyle = { ...style };
  279. let children: React.ReactNode[] = Children.toArray(originChildren);
  280. const footers: React.ReactNode[] = [];
  281. const headers: React.ReactNode[] = [];
  282. if (React.isValidElement(footer)) {
  283. footers.push(<Footer key={0}>{footer}</Footer>);
  284. } else if (footer && typeof footer === 'object') {
  285. footers.push(<Footer key={0} {...footer} />);
  286. }
  287. if (React.isValidElement(header)) {
  288. headers.push(<Header key={0}>{header}</Header>);
  289. } else if (header && typeof header === 'object') {
  290. headers.push(<Header key={0} {...header} />);
  291. }
  292. if (Array.isArray(children) && children.length) {
  293. children = [...children];
  294. let childrenLength = children.length;
  295. for (let i = 0; i < childrenLength; i++) {
  296. const child = children[i];
  297. if ((child as any).type === Footer || get(child, 'type.name') === 'NavFooter') {
  298. footers.push(child);
  299. children.splice(i, 1);
  300. i--;
  301. childrenLength--;
  302. } else if ((child as any).type === Header || get(child, 'type.name') === 'NavHeader') {
  303. headers.push(child);
  304. children.splice(i, 1);
  305. i--;
  306. childrenLength--;
  307. }
  308. }
  309. }
  310. const finalCls = cls(prefixCls, className, {
  311. [`${prefixCls}-collapsed`]: isCollapsed,
  312. [`${prefixCls}-horizontal`]: mode === 'horizontal',
  313. [`${prefixCls}-vertical`]: mode === 'vertical',
  314. });
  315. const headerListOuterCls = cls(`${prefixCls}-header-list-outer`, {
  316. [`${prefixCls}-header-list-outer-collapsed`]: isCollapsed,
  317. });
  318. if (this.itemsChanged) {
  319. this.adapter.setCache('itemElems', this.renderItems(items));
  320. }
  321. return (
  322. <LocaleConsumer componentName="Navigation">
  323. {locale => (
  324. <NavContext.Provider
  325. value={{
  326. subNavCloseDelay,
  327. subNavOpenDelay,
  328. subNavMotion,
  329. tooltipShowDelay,
  330. tooltipHideDelay,
  331. openKeys,
  332. openKeysIsControlled: this.isControlled('openKeys') && mode === 'vertical' && !isCollapsed,
  333. // canUpdateOpenKeys: mode === 'vertical' && !isCollapsed,
  334. canUpdateOpenKeys: true,
  335. selectedKeys,
  336. selectedKeysIsControlled: this.isControlled('selectedKeys'),
  337. isCollapsed,
  338. onCollapseChange: this.onCollapseChange,
  339. mode,
  340. onSelect,
  341. onOpenChange,
  342. updateOpenKeys,
  343. addOpenKeys,
  344. removeOpenKeys,
  345. updateSelectedKeys,
  346. addSelectedKeys,
  347. removeSelectedKeys,
  348. onClick,
  349. locale,
  350. prefixCls,
  351. toggleIconPosition,
  352. limitIndent
  353. } as any}
  354. >
  355. <div className={finalCls} style={finalStyle}>
  356. <div className={`${prefixCls}-inner`}>
  357. <div className={headerListOuterCls}>
  358. {headers}
  359. <div style={bodyStyle} className={`${prefixCls}-list-wrapper`}>
  360. <ul role="menu" aria-orientation={mode} className={`${prefixCls}-list`}>
  361. {this.adapter.getCache('itemElems')}
  362. {children}
  363. </ul>
  364. </div>
  365. </div>
  366. {footers}
  367. </div>
  368. </div>
  369. </NavContext.Provider>
  370. )}
  371. </LocaleConsumer>
  372. );
  373. }
  374. }
  375. export default Nav;