CustomNav.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import React, {
  2. useEffect, useState, useRef, useMemo, useCallback,
  3. } from 'react';
  4. import { Breakpoint } from '@growi/ui/dist/interfaces/breakpoints';
  5. import Nav from 'reactstrap/es/Nav';
  6. import NavItem from 'reactstrap/es/NavItem';
  7. import NavLink from 'reactstrap/es/NavLink';
  8. import { ICustomNavTabMappings } from '~/interfaces/ui';
  9. import styles from './CustomNav.module.scss';
  10. function getBreakpointOneLevelLarger(breakpoint: Breakpoint): Omit<Breakpoint, 'xs' | 'sm'> {
  11. switch (breakpoint) {
  12. case 'xs':
  13. return 'sm';
  14. case 'sm':
  15. return 'md';
  16. case 'md':
  17. return 'lg';
  18. case 'lg':
  19. return 'xl';
  20. case 'xl':
  21. default:
  22. return 'xxl';
  23. }
  24. }
  25. type CustomNavDropdownProps = {
  26. navTabMapping: ICustomNavTabMappings,
  27. activeTab: string,
  28. onNavSelected?: (selectedTabKey: string) => void,
  29. };
  30. export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element => {
  31. const {
  32. activeTab, navTabMapping, onNavSelected,
  33. } = props;
  34. const { Icon, i18n } = navTabMapping[activeTab];
  35. const menuItemClickHandler = useCallback((key) => {
  36. if (onNavSelected != null) {
  37. onNavSelected(key);
  38. }
  39. }, [onNavSelected]);
  40. return (
  41. <div className="grw-custom-nav-dropdown btn-group btn-block">
  42. <button
  43. className="btn btn-outline-primary btn-lg btn-block dropdown-toggle text-right"
  44. type="button"
  45. data-toggle="dropdown"
  46. aria-haspopup="true"
  47. aria-expanded="false"
  48. >
  49. <span className="float-left">
  50. { Icon != null && <Icon /> } {i18n}
  51. </span>
  52. </button>
  53. <div className="dropdown-menu dropdown-menu-right">
  54. {Object.entries(navTabMapping).map(([key, value]) => {
  55. const isActive = activeTab === key;
  56. const _isLinkEnabled = value.isLinkEnabled ?? true;
  57. const isLinkEnabled = typeof _isLinkEnabled === 'boolean' ? _isLinkEnabled : _isLinkEnabled(value);
  58. const { Icon, i18n } = value;
  59. return (
  60. <button
  61. key={key}
  62. type="button"
  63. className={`dropdown-item px-3 py-2 ${isActive ? 'active' : ''}`}
  64. disabled={!isLinkEnabled}
  65. onClick={() => menuItemClickHandler(key)}
  66. >
  67. { Icon != null && <Icon /> } {i18n}
  68. </button>
  69. );
  70. })}
  71. </div>
  72. </div>
  73. );
  74. };
  75. type CustomNavTabProps = {
  76. activeTab: string,
  77. navTabMapping: ICustomNavTabMappings,
  78. onNavSelected?: (selectedTabKey: string) => void,
  79. hideBorderBottom?: boolean,
  80. breakpointToHideInactiveTabsDown?: Breakpoint,
  81. navRightElement?: JSX.Element,
  82. };
  83. export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
  84. const [sliderWidth, setSliderWidth] = useState(0);
  85. const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
  86. const {
  87. activeTab, navTabMapping, onNavSelected,
  88. hideBorderBottom,
  89. breakpointToHideInactiveTabsDown, navRightElement,
  90. } = props;
  91. const navContainerRef = useRef<HTMLDivElement>(null);
  92. const navTabRefs: { [key: string]: HTMLAnchorElement } = useMemo(() => {
  93. const obj = {};
  94. Object.keys(navTabMapping).forEach((key) => {
  95. obj[key] = React.createRef();
  96. });
  97. return obj;
  98. }, [navTabMapping]);
  99. const navLinkClickHandler = useCallback((key) => {
  100. if (onNavSelected != null) {
  101. onNavSelected(key);
  102. }
  103. }, [onNavSelected]);
  104. function registerNavLink(key: string, anchorElem: HTMLAnchorElement | null) {
  105. if (anchorElem != null) {
  106. navTabRefs[key] = anchorElem;
  107. }
  108. }
  109. // Might make this dynamic for px, %, pt, em
  110. function getPercentage(min, max) {
  111. return min / max * 100;
  112. }
  113. useEffect(() => {
  114. if (activeTab == null || activeTab === '') {
  115. return;
  116. }
  117. if (navContainerRef.current == null) {
  118. return;
  119. }
  120. const navContainer = navContainerRef.current;
  121. let marginLeft = 0;
  122. for (const [key, anchorElem] of Object.entries(navTabRefs)) {
  123. const width = getPercentage(anchorElem.offsetWidth, navContainer.offsetWidth);
  124. if (key === activeTab) {
  125. setSliderWidth(width);
  126. setSliderMarginLeft(marginLeft);
  127. break;
  128. }
  129. marginLeft += width;
  130. }
  131. }, [activeTab, navTabRefs, navTabMapping]);
  132. // determine inactive classes to hide NavItem
  133. const inactiveClassnames: string[] = [];
  134. if (breakpointToHideInactiveTabsDown != null) {
  135. const breakpointOneLevelLarger = getBreakpointOneLevelLarger(breakpointToHideInactiveTabsDown);
  136. inactiveClassnames.push('d-none');
  137. inactiveClassnames.push(`d-${breakpointOneLevelLarger}-block`);
  138. }
  139. return (
  140. <div className={`grw-custom-nav-tab ${styles['grw-custom-nav-tab']}`}>
  141. <div ref={navContainerRef} className="d-flex justify-content-between">
  142. <Nav className="nav-title">
  143. {Object.entries(navTabMapping).map(([key, value]) => {
  144. const isActive = activeTab === key;
  145. const _isLinkEnabled = value.isLinkEnabled ?? true;
  146. const isLinkEnabled = typeof _isLinkEnabled === 'boolean' ? _isLinkEnabled : _isLinkEnabled(value);
  147. const { Icon, i18n } = value;
  148. return (
  149. <NavItem
  150. key={key}
  151. className={`p-0 ${isActive ? 'active' : inactiveClassnames.join(' ')}`}
  152. >
  153. <NavLink type="button" key={key} innerRef={elm => registerNavLink(key, elm)} disabled={!isLinkEnabled} onClick={() => navLinkClickHandler(key)}>
  154. { Icon != null && <Icon /> } {i18n}
  155. </NavLink>
  156. </NavItem>
  157. );
  158. })}
  159. </Nav>
  160. {navRightElement}
  161. </div>
  162. <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
  163. { !hideBorderBottom && <hr className="my-0 border-top-0 border-bottom" /> }
  164. </div>
  165. );
  166. };
  167. type CustomNavProps = {
  168. activeTab: string,
  169. navTabMapping: ICustomNavTabMappings,
  170. onNavSelected?: (selectedTabKey: string) => void,
  171. hideBorderBottom?: boolean,
  172. breakpointToHideInactiveTabsDown?: Breakpoint,
  173. breakpointToSwitchDropdownDown?: Breakpoint,
  174. };
  175. const CustomNav = (props: CustomNavProps): JSX.Element => {
  176. const tabClassnames = ['d-none'];
  177. const dropdownClassnames = ['d-block'];
  178. // determine classes to show/hide
  179. const breakpointOneLevelLarger = getBreakpointOneLevelLarger(props.breakpointToSwitchDropdownDown ?? 'sm');
  180. tabClassnames.push(`d-${breakpointOneLevelLarger}-block`);
  181. dropdownClassnames.push(`d-${breakpointOneLevelLarger}-none`);
  182. return (
  183. <div className="grw-custom-nav">
  184. <div className={tabClassnames.join(' ')}>
  185. <CustomNavTab {...props} />
  186. </div>
  187. <div className={dropdownClassnames.join(' ')}>
  188. <CustomNavDropdown {...props} />
  189. </div>
  190. </div>
  191. );
  192. };
  193. export default CustomNav;