CustomNav.tsx 7.3 KB

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