index.tsx 17 KB

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