@octopusdeploy/design-system-components
Version:
The design systems component library.
238 lines (237 loc) • 13.4 kB
JavaScript
"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,
});