UNPKG

@octopusdeploy/design-system-components

Version:
238 lines (237 loc) • 13.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.NavigationBar = NavigationBar; const jsx_runtime_1 = require("react/jsx-runtime"); const css_1 = require("@emotion/css"); const design_system_icons_1 = require("@octopusdeploy/design-system-icons"); const design_system_tokens_1 = require("@octopusdeploy/design-system-tokens"); const type_utils_1 = require("@octopusdeploy/type-utils"); const lodash_debounce_1 = __importDefault(require("lodash.debounce")); const lodash_throttle_1 = __importDefault(require("lodash.throttle")); const react_1 = require("react"); const breakpoints_1 = require("../../hooks/breakpoints"); const isHTMLElement_1 = require("../../utils/isHTMLElement"); const resetStyles_1 = require("../../utils/resetStyles"); const Menu_1 = require("../Menu"); const NavigationBarActions_1 = require("./NavigationBarActions"); const NavigationBarButton_1 = require("./NavigationBarButton"); const NavigationBarIconButton_1 = require("./NavigationBarIconButton"); const NavigationBarIconLink_1 = require("./NavigationBarIconLink"); const NavigationBarItem_1 = require("./NavigationBarItem"); /** * The NavigationBar component is used to display a navigation bar with a logo, items, and actions. * It is responsive and will display a mobile version of the navigation bar on smaller screens. * It is currently used exclusively in the Control Centre. * * @param props - The props for the NavigationBar component. * @param props.logo - The logo to display in the navigation bar. * @param props.items - The items to display in the navigation bar. * @param props.actions - The actions to display in the navigation bar. * @returns The NavigationBar component. */ function NavigationBar({ logo, items, actions }) { const isLargerThanIpad = (0, breakpoints_1.useIsLargerThanIpadResolution)(); const largeNavigationBarGridStyles = logo ? largeNavigationBarWithLogoStyles : largeNavigationBarWithoutLogoStyles; const smallNavigationBarGridStyles = logo ? smallNavigationBarWithLogoStyles : smallNavigationBarWithoutLogoStyles; const navigationBarGridStyles = isLargerThanIpad ? largeNavigationBarGridStyles : smallNavigationBarGridStyles; return ((0, jsx_runtime_1.jsxs)("nav", { className: (0, css_1.cx)(navigationBarStyles, navigationBarGridStyles), children: [logo, isLargerThanIpad ? (0, jsx_runtime_1.jsx)(NavigationBarLinksList, { items: items }) : (0, jsx_runtime_1.jsx)(MobileNavigationLinks, { items: items }), actions && ((0, jsx_runtime_1.jsx)(NavigationBarActionsContainer, { children: (0, jsx_runtime_1.jsx)(NavigationBarActions_1.NavigationBarActions, { items: actions }) }))] })); } NavigationBar.Button = NavigationBarButton_1.NavigationBarButton; NavigationBar.DeprecatedIconButton = NavigationBarIconButton_1.NavigationBarIconButton; NavigationBar.IconLink = NavigationBarIconLink_1.NavigationBarIconLink; function MobileNavigationLinks({ items }) { const [openMenu, menuState, moreButtonAriaAttributes] = (0, Menu_1.useMenuState)(); const menuItems = convertNavigationBarItemToMenuItem(items); return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(NavigationBarButton_1.NavigationBarButton, { label: "Menu", icon: (0, jsx_runtime_1.jsx)(design_system_icons_1.ChevronDownIcon, { size: 24 }), accessibleName: "Show Menu", onClick: openMenu, ...moreButtonAriaAttributes }), (0, jsx_runtime_1.jsx)(Menu_1.SimpleMenu, { menuState: menuState, items: menuItems, accessibleName: "Menu" })] })); } function NavigationBarLinksList({ items }) { const containerRef = (0, react_1.useRef)(null); const lastContainerWidthRef = (0, react_1.useRef)(0); const lastMoreListItemWidthRef = (0, react_1.useRef)(0); const itemPositions = (0, react_1.useRef)(); const [hasMeasuredItems, setHasMeasuredItems] = (0, react_1.useState)(false); const [numberOfItemsToShow, setNumberOfItemsToShow] = (0, react_1.useState)(); const recalculateItemsToShow = () => { const currentItemPositions = itemPositions.current; if (currentItemPositions === undefined || currentItemPositions.length === 0) { return; } const currentContainerWidth = lastContainerWidthRef.current; const lastItemPosition = currentItemPositions[currentItemPositions.length - 1]; const showMoreButton = lastItemPosition.right > currentContainerWidth; // If all the items fit in the available width, then we don't need to account for the more button at all. if (!showMoreButton) { setNumberOfItemsToShow(currentItemPositions.length); return; } // Find last index where the more button will fit, the more button will be shown at this index. // If it doesn't fit anywhere, it should be shown at the first index. const moreButtonIndex = Math.max(findLastIndex(currentItemPositions, (position) => position.left + lastMoreListItemWidthRef.current <= currentContainerWidth), 0); setNumberOfItemsToShow(moreButtonIndex); }; (0, react_1.useLayoutEffect)(() => { if (containerRef.current === null) { return; } // Any child list items of the container should be observed for size changes. // This can happen when fonts are loaded after the initial mount, which will invalidate all our measurements. const itemWidthMap = new Map(); const onItemResize = (entries) => { const targets = entries.map((e) => e.target).filter(isHTMLElement_1.isHTMLElement); const shouldInvalidateMeasurements = targets.some((t) => { const previousWidth = itemWidthMap.get(t); return previousWidth !== undefined && previousWidth !== t.clientWidth; }); if (shouldInvalidateMeasurements) { setHasMeasuredItems(false); } else { targets.forEach((t) => itemWidthMap.set(t, t.clientWidth)); } }; const itemsResizeObserver = new ResizeObserver((0, lodash_debounce_1.default)(onItemResize, 100)); // When the container resizes, we recalculate which items to display and which to shove into the 'More' menu. const onContainerResize = (entries) => { const container = entries[0].target; if (container.clientWidth !== lastContainerWidthRef.current) { lastContainerWidthRef.current = container.clientWidth; recalculateItemsToShow(); } }; // Child list items are added removed dynamically on container resize, we need to ensure that they are all observed for size changes. const onContainerMutated = (mutations) => { const containerChildListMutated = mutations.some((mutation) => mutation.type === "childList"); if (containerChildListMutated) { itemWidthMap.clear(); itemsResizeObserver.disconnect(); const items = Array.from(containerRef.current?.children ?? []); items.forEach((item) => itemsResizeObserver.observe(item)); } }; lastContainerWidthRef.current = containerRef.current.clientWidth; const containerResizeObserver = new ResizeObserver((0, lodash_throttle_1.default)(onContainerResize, 100)); const containerMutationObserver = new MutationObserver(onContainerMutated); containerResizeObserver.observe(containerRef.current); containerMutationObserver.observe(containerRef.current, { childList: true }); return () => { itemsResizeObserver.disconnect(); containerResizeObserver.disconnect(); containerMutationObserver.disconnect(); }; }, []); (0, react_1.useLayoutEffect)(() => { if (containerRef.current === null || hasMeasuredItems) { return; } const containerOffsetLeft = containerRef.current.offsetLeft; itemPositions.current = Array.from(containerRef.current.children) .slice(0, -1) // Exclude the last item, which should always be the 'More' button in this state .filter(isHTMLElement_1.isHTMLElement) .map((item) => ({ left: item.offsetLeft - containerOffsetLeft, right: item.offsetLeft - containerOffsetLeft + item.offsetWidth, })); recalculateItemsToShow(); setHasMeasuredItems(true); }, [hasMeasuredItems]); const onMoreListItemRef = (0, react_1.useCallback)((element) => { lastMoreListItemWidthRef.current = element ? element.clientWidth : lastMoreListItemWidthRef.current; }, []); const [openMoreMenu, moreMenuState, moreButtonAriaAttributes] = (0, Menu_1.useMenuState)(); const itemsToShow = hasMeasuredItems ? items.slice(0, numberOfItemsToShow) : items; const itemsToHideUnderMore = hasMeasuredItems ? items.slice(numberOfItemsToShow) : []; const showMoreButton = !hasMeasuredItems || itemsToHideUnderMore.length > 0; const moreMenuItems = convertNavigationBarItemToMenuItem(itemsToHideUnderMore); return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: (0, jsx_runtime_1.jsxs)("ul", { className: navigationItemsListStyles, ref: containerRef, children: [itemsToShow.map((item) => ((0, jsx_runtime_1.jsx)("li", { children: (0, jsx_runtime_1.jsx)(NavigationBarItem_1.NavigationBarItem, { item: item }) }, item.type === "link" ? item.label : item.key))), showMoreButton && ((0, jsx_runtime_1.jsxs)("li", { ref: onMoreListItemRef, children: [(0, jsx_runtime_1.jsx)(NavigationBarButton_1.NavigationBarButton, { label: "More", icon: (0, jsx_runtime_1.jsx)(design_system_icons_1.ChevronDownIcon, { size: 24 }), accessibleName: "Open more menu", onClick: openMoreMenu, ...moreButtonAriaAttributes }), (0, jsx_runtime_1.jsx)(Menu_1.SimpleMenu, { menuState: moreMenuState, items: moreMenuItems, accessibleName: "More" })] }))] }) })); } function convertNavigationBarItemToMenuItem(items) { const moreMenuItems = items .map((item) => { if (item.type === "link") { const result = { type: "internal-link", label: item.label, path: item.href, showAsActive: item.showLinkAsActive ?? "if path partially matches", }; return result; } if (item.type === "custom" && item.fallbackLink) { const result = { type: "internal-link", label: item.fallbackLink.label, path: item.fallbackLink.href, showAsActive: item.fallbackLink.showLinkAsActive ?? "if path partially matches", }; return result; } return undefined; }) .filter(type_utils_1.isNotNull); return moreMenuItems; } function NavigationBarActionsContainer({ children }) { const isLargerThanIpad = (0, breakpoints_1.useIsLargerThanIpadResolution)(); return (0, jsx_runtime_1.jsx)("div", { className: (0, css_1.cx)(navigationBarActionsContainerStyles, { [navigationBarActionsContainerMobileStyles]: !isLargerThanIpad }), children: children }); } NavigationBarActionsContainer.displayName = "NavigationBarActionsContainer"; // This exists natively in JS but is not in our version of TS, this should be replaced when available. function findLastIndex(array, callbackFn) { for (let i = array.length - 1; i >= 0; i--) { if (callbackFn(array[i])) { return i; } } return -1; } const navigationBarStyles = (0, css_1.css)({ background: design_system_tokens_1.colorScales.navy[900], color: design_system_tokens_1.colorScales.white, fill: design_system_tokens_1.colorScales.white, padding: `0 ${design_system_tokens_1.space[16]}`, width: "100%", boxSizing: "border-box", display: "grid", columnGap: design_system_tokens_1.space[24], //We need an explicit height in order to avoid items/actions from expanding //the navbar passed what the design accounts for. gridAutoRows: "4rem", alignItems: "center", }); const largeNavigationBarWithLogoStyles = (0, css_1.css)({ gridTemplateColumns: "auto 1fr auto", gridTemplateAreas: `"logo items actions"`, }); const largeNavigationBarWithoutLogoStyles = (0, css_1.css)({ gridTemplateColumns: "1fr auto", gridTemplateAreas: `"items actions"`, }); const smallNavigationBarWithLogoStyles = (0, css_1.css)({ gridTemplateColumns: "1fr auto", gridTemplateAreas: `"logo items" "actions actions"`, }); const smallNavigationBarWithoutLogoStyles = (0, css_1.css)({ gridTemplateColumns: "1fr", gridTemplateAreas: `"items" "actions"`, }); const navigationBarActionsContainerStyles = (0, css_1.css)({ gridArea: "actions", }); const navigationBarActionsContainerMobileStyles = (0, css_1.css)({ borderTop: `1px solid ${design_system_tokens_1.colorScales.navy[700]}`, display: "flex", alignItems: "center", height: "100%", width: "100%", }); const navigationItemsListStyles = (0, css_1.css)({ ...resetStyles_1.resetStyles.list, flex: 1, display: "flex", alignItems: "center", gap: design_system_tokens_1.space[24], minWidth: 0, });