CustomNav.tsx 6.7 KB

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