@primer/react
Version:
An implementation of GitHub's Primer Design System using React
375 lines (361 loc) • 18.5 kB
JavaScript
'use strict';
var React = require('react');
var sx = require('../sx.js');
var UnderlineNavContext = require('./UnderlineNavContext.js');
var useResizeObserver = require('../hooks/useResizeObserver.js');
var ThemeProvider = require('../ThemeProvider.js');
var _VisuallyHidden = require('../_VisuallyHidden.js');
var styles = require('./styles.js');
var UnderlineTabbedInterface = require('../internal/components/UnderlineTabbedInterface.js');
var styled = require('styled-components');
var octiconsReact = require('@primer/octicons-react');
var useOnEscapePress = require('../hooks/useOnEscapePress.js');
var useOnOutsideClick = require('../hooks/useOnOutsideClick.js');
var useId = require('../hooks/useId.js');
var index = require('../ActionList/index.js');
var defaultSxProp = require('../utils/defaultSxProp.js');
var invariant = require('../utils/invariant.js');
var jsxRuntime = require('react/jsx-runtime');
var Box = require('../Box/Box.js');
var Button = require('../Button/Button.js');
var CounterLabel = require('../CounterLabel/CounterLabel.js');
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
var React__default = /*#__PURE__*/_interopDefault(React);
var styled__default = /*#__PURE__*/_interopDefault(styled);
// When page is loaded, we don't have ref for the more button as it is not on the DOM yet.
// However, we need to calculate number of possible items when the more button present as well. So using the width of the more button as a constant.
const MORE_BTN_WIDTH = 86;
// The height is needed to make sure we don't have a layout shift when the more button is the only item in the nav.
const MORE_BTN_HEIGHT = 45;
// Needed this because passing a ref using HTMLULListElement to `Box` causes a type error
styled__default.default.ul.withConfig({
displayName: "UnderlineNav__NavigationList",
componentId: "sc-1jfr31k-0"
})(["", ";"], sx.default);
const MoreMenuListItem = styled__default.default.li.withConfig({
displayName: "UnderlineNav__MoreMenuListItem",
componentId: "sc-1jfr31k-1"
})(["display:flex;align-items:center;height:", "px;"], MORE_BTN_HEIGHT);
const overflowEffect = (navWidth, moreMenuWidth, childArray, childWidthArray, noIconChildWidthArray, updateListAndMenu) => {
let iconsVisible = true;
if (childWidthArray.length === 0) {
updateListAndMenu({
items: childArray,
menuItems: []
}, iconsVisible);
}
const numberOfItemsPossible = calculatePossibleItems(childWidthArray, navWidth);
const numberOfItemsWithoutIconPossible = calculatePossibleItems(noIconChildWidthArray, navWidth);
// We need to take more menu width into account when calculating the number of items possible
const numberOfItemsPossibleWithMoreMenu = calculatePossibleItems(noIconChildWidthArray, navWidth, moreMenuWidth || MORE_BTN_WIDTH);
const items = [];
const menuItems = [];
// First, we check if we can fit all the items with their icons
if (childArray.length <= numberOfItemsPossible) {
items.push(...childArray);
} else if (childArray.length <= numberOfItemsWithoutIconPossible) {
// if we can't fit all the items with their icons, we check if we can fit all the items without their icons
iconsVisible = false;
items.push(...childArray);
} else {
// if we can't fit all the items without their icons, we keep the icons hidden and show the ones that doesn't fit into the list in the overflow menu
iconsVisible = false;
/* Below is an accessibility requirement. Never show only one item in the overflow menu.
* If there is only one item left to display in the overflow menu according to the calculation,
* we need to pull another item from the list into the overflow menu.
*/
const numberOfItemsInMenu = childArray.length - numberOfItemsPossibleWithMoreMenu;
const numberOfListItems = numberOfItemsInMenu === 1 ? numberOfItemsPossibleWithMoreMenu - 1 : numberOfItemsPossibleWithMoreMenu;
for (const [index, child] of childArray.entries()) {
if (index < numberOfListItems) {
items.push(child);
} else {
const ariaCurrent = child.props['aria-current'];
const isCurrent = Boolean(ariaCurrent) && ariaCurrent !== 'false';
// We need to make sure to keep the selected item always visible.
// To do that, we swap the selected item with the last item in the list to make it visible. (When there is at least 1 item in the list to swap.)
if (isCurrent && numberOfListItems > 0) {
// If selected item couldn't make in to the list, we swap it with the last item in the list.
const indexToReplaceAt = numberOfListItems - 1; // because we are replacing the last item in the list
// splice method modifies the array by removing 1 item here at the given index and replace it with the "child" element then returns the removed item.
const propsectiveAction = items.splice(indexToReplaceAt, 1, child)[0];
menuItems.push(propsectiveAction);
} else {
menuItems.push(child);
}
}
}
}
updateListAndMenu({
items,
menuItems
}, iconsVisible);
};
const getValidChildren = children => {
return React__default.default.Children.toArray(children).filter(child => /*#__PURE__*/React__default.default.isValidElement(child));
};
const calculatePossibleItems = (childWidthArray, navWidth, moreMenuWidth = 0) => {
const widthToFit = navWidth - moreMenuWidth;
let breakpoint = childWidthArray.length; // assume all items will fit
let sumsOfChildWidth = 0;
for (const [index, childWidth] of childWidthArray.entries()) {
sumsOfChildWidth = sumsOfChildWidth + childWidth.width + UnderlineTabbedInterface.GAP;
if (sumsOfChildWidth > widthToFit) {
breakpoint = index;
break;
} else {
continue;
}
}
return breakpoint;
};
const UnderlineNav = /*#__PURE__*/React.forwardRef(({
as = 'nav',
'aria-label': ariaLabel,
sx: sxProp = defaultSxProp.defaultSxProp,
loadingCounters = false,
variant = 'inset',
className,
children
}, forwardedRef) => {
var _listRef$current2;
const backupRef = React.useRef(null);
const navRef = forwardedRef !== null && forwardedRef !== void 0 ? forwardedRef : backupRef;
const listRef = React.useRef(null);
const moreMenuRef = React.useRef(null);
const moreMenuBtnRef = React.useRef(null);
const containerRef = React__default.default.useRef(null);
const disclosureWidgetId = useId.useId();
const {
theme
} = ThemeProvider.useTheme();
const [isWidgetOpen, setIsWidgetOpen] = React.useState(false);
const [iconsVisible, setIconsVisible] = React.useState(true);
const [childWidthArray, setChildWidthArray] = React.useState([]);
const [noIconChildWidthArray, setNoIconChildWidthArray] = React.useState([]);
const validChildren = getValidChildren(children);
// Responsive props object manages which items are in the list and which items are in the menu.
const [responsiveProps, setResponsiveProps] = React.useState({
items: validChildren,
menuItems: []
});
// Make sure to have the fresh props data for list items when children are changed (keeping aria-current up-to-date)
const listItems = responsiveProps.items.map(item => {
var _validChildren$find;
return (_validChildren$find = validChildren.find(child => child.key === item.key)) !== null && _validChildren$find !== void 0 ? _validChildren$find : item;
});
// Make sure to have the fresh props data for menu items when children are changed (keeping aria-current up-to-date)
const menuItems = responsiveProps.menuItems.map(menuItem => {
var _validChildren$find2;
return (_validChildren$find2 = validChildren.find(child => child.key === menuItem.key)) !== null && _validChildren$find2 !== void 0 ? _validChildren$find2 : menuItem;
});
// This is the case where the viewport is too narrow to show any list item with the more menu. In this case, we only show the dropdown
const onlyMenuVisible = responsiveProps.items.length === 0;
if (process.env.NODE_ENV !== "production") {
// Practically, this is not a conditional hook, it is just making sure this hook runs only on DEV not PROD.
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
// Address illegal state where there are multiple items that have `aria-current='page'` attribute
const activeElements = validChildren.filter(child => {
return child.props['aria-current'] !== undefined;
});
!(activeElements.length <= 1) ? process.env.NODE_ENV !== "production" ? invariant.invariant(false, 'Only one current element is allowed') : invariant.invariant(false) : void 0;
!ariaLabel ? process.env.NODE_ENV !== "production" ? invariant.invariant(false, 'Use the `aria-label` prop to provide an accessible label for assistive technology') : invariant.invariant(false) : void 0;
});
}
function getItemsWidth(itemText) {
var _noIconChildWidthArra, _noIconChildWidthArra2;
return (_noIconChildWidthArra = (_noIconChildWidthArra2 = noIconChildWidthArray.find(item => item.text === itemText)) === null || _noIconChildWidthArra2 === void 0 ? void 0 : _noIconChildWidthArra2.width) !== null && _noIconChildWidthArra !== void 0 ? _noIconChildWidthArra : 0;
}
const swapMenuItemWithListItem = (prospectiveListItem, indexOfProspectiveListItem, event, callback) => {
var _listRef$current$getB, _listRef$current;
// get the selected menu item's width
const widthToFitIntoList = getItemsWidth(prospectiveListItem.props.children);
// Check if there is any empty space on the right side of the list
const availableSpace = navRef.current.getBoundingClientRect().width - ((_listRef$current$getB = (_listRef$current = listRef.current) === null || _listRef$current === void 0 ? void 0 : _listRef$current.getBoundingClientRect().width) !== null && _listRef$current$getB !== void 0 ? _listRef$current$getB : 0);
// Calculate how many items need to be pulled in to the menu to make room for the selected menu item
// I.e. if we need to pull 2 items in (index 0 and index 1), breakpoint (index) will return 1.
const index = getBreakpointForItemSwapping(widthToFitIntoList, availableSpace);
const indexToSliceAt = responsiveProps.items.length - 1 - index;
// Form the new list of items
const itemsLeftInList = [...responsiveProps.items].slice(0, indexToSliceAt);
const updatedItemList = [...itemsLeftInList, prospectiveListItem];
// Form the new menu items
const itemsToAddToMenu = [...responsiveProps.items].slice(indexToSliceAt);
const updatedMenuItems = [...menuItems];
// Add itemsToAddToMenu array's items to the menu at the index of the prospectiveListItem and remove 1 count of items (prospectiveListItem)
updatedMenuItems.splice(indexOfProspectiveListItem, 1, ...itemsToAddToMenu);
callback({
items: updatedItemList,
menuItems: updatedMenuItems
}, false);
};
// How many items do we need to pull in to the menu to make room for the selected menu item.
function getBreakpointForItemSwapping(widthToFitIntoList, availableSpace) {
let widthToSwap = 0;
let breakpoint = 0;
for (const [index, item] of [...responsiveProps.items].reverse().entries()) {
widthToSwap += getItemsWidth(item.props.children);
if (widthToFitIntoList < widthToSwap + availableSpace) {
breakpoint = index;
break;
}
}
return breakpoint;
}
const updateListAndMenu = React.useCallback((props, displayIcons) => {
setResponsiveProps(props);
setIconsVisible(displayIcons);
}, []);
const setChildrenWidth = React.useCallback(size => {
setChildWidthArray(arr => {
const newArr = [...arr, size];
return newArr;
});
}, []);
const setNoIconChildrenWidth = React.useCallback(size => {
setNoIconChildWidthArray(arr => {
const newArr = [...arr, size];
return newArr;
});
}, []);
const closeOverlay = React__default.default.useCallback(() => {
setIsWidgetOpen(false);
}, [setIsWidgetOpen]);
const focusOnMoreMenuBtn = React__default.default.useCallback(() => {
var _moreMenuBtnRef$curre;
(_moreMenuBtnRef$curre = moreMenuBtnRef.current) === null || _moreMenuBtnRef$curre === void 0 ? void 0 : _moreMenuBtnRef$curre.focus();
}, []);
const onAnchorClick = React.useCallback(event => {
if (event.defaultPrevented || event.button !== 0) {
return;
}
setIsWidgetOpen(isWidgetOpen => !isWidgetOpen);
}, []);
useOnEscapePress.useOnEscapePress(event => {
if (isWidgetOpen) {
event.preventDefault();
closeOverlay();
focusOnMoreMenuBtn();
}
}, [isWidgetOpen]);
useOnOutsideClick.useOnOutsideClick({
onClickOutside: closeOverlay,
containerRef,
ignoreClickRefs: [moreMenuBtnRef]
});
useResizeObserver.useResizeObserver(resizeObserverEntries => {
var _moreMenuRef$current$, _moreMenuRef$current;
const navWidth = resizeObserverEntries[0].contentRect.width;
const moreMenuWidth = (_moreMenuRef$current$ = (_moreMenuRef$current = moreMenuRef.current) === null || _moreMenuRef$current === void 0 ? void 0 : _moreMenuRef$current.getBoundingClientRect().width) !== null && _moreMenuRef$current$ !== void 0 ? _moreMenuRef$current$ : 0;
navWidth !== 0 && overflowEffect(navWidth, moreMenuWidth, validChildren, childWidthArray, noIconChildWidthArray, updateListAndMenu);
}, navRef);
return /*#__PURE__*/jsxRuntime.jsxs(UnderlineNavContext.UnderlineNavContext.Provider, {
value: {
theme,
setChildrenWidth,
setNoIconChildrenWidth,
loadingCounters,
iconsVisible
},
children: [ariaLabel && /*#__PURE__*/jsxRuntime.jsx(_VisuallyHidden, {
as: "h2",
children: `${ariaLabel} navigation`
}), /*#__PURE__*/jsxRuntime.jsx(UnderlineTabbedInterface.UnderlineWrapper, {
as: as,
"aria-label": ariaLabel,
className: className,
ref: navRef,
sx: sxProp,
"data-variant": variant,
children: /*#__PURE__*/jsxRuntime.jsxs(UnderlineTabbedInterface.UnderlineItemList, {
ref: listRef,
role: "list",
children: [listItems, menuItems.length > 0 && /*#__PURE__*/jsxRuntime.jsxs(MoreMenuListItem, {
ref: moreMenuRef,
children: [!onlyMenuVisible && /*#__PURE__*/jsxRuntime.jsx(Box, {
sx: styles.getDividerStyle(theme)
}), /*#__PURE__*/jsxRuntime.jsx(Button.ButtonComponent, {
ref: moreMenuBtnRef,
sx: styles.moreBtnStyles,
"aria-controls": disclosureWidgetId,
"aria-expanded": isWidgetOpen,
onClick: onAnchorClick,
trailingAction: octiconsReact.TriangleDownIcon,
children: /*#__PURE__*/jsxRuntime.jsx(Box, {
as: "span",
children: onlyMenuVisible ? /*#__PURE__*/jsxRuntime.jsxs(jsxRuntime.Fragment, {
children: [/*#__PURE__*/jsxRuntime.jsxs(_VisuallyHidden, {
as: "span",
children: [`${ariaLabel}`, "\xA0"]
}), "Menu"]
}) : /*#__PURE__*/jsxRuntime.jsxs(jsxRuntime.Fragment, {
children: ["More", /*#__PURE__*/jsxRuntime.jsxs(_VisuallyHidden, {
as: "span",
children: ["\xA0", `${ariaLabel} items`]
})]
})
})
}), /*#__PURE__*/jsxRuntime.jsx(index.ActionList, {
selectionVariant: "single",
ref: containerRef,
id: disclosureWidgetId,
sx: (_listRef$current2 = listRef.current) !== null && _listRef$current2 !== void 0 && _listRef$current2.clientWidth && listRef.current.clientWidth >= styles.baseMenuMinWidth ? styles.baseMenuStyles : styles.menuStyles(containerRef.current, listRef.current),
style: {
display: isWidgetOpen ? 'block' : 'none'
},
children: menuItems.map((menuItem, index$1) => {
const {
children: menuItemChildren,
counter,
'aria-current': ariaCurrent,
onSelect,
...menuItemProps
} = menuItem.props;
// This logic is used to pop the selected item out of the menu and into the list when the navigation is control externally
if (Boolean(ariaCurrent) && ariaCurrent !== 'false') {
const event = new MouseEvent('click');
!onlyMenuVisible && swapMenuItemWithListItem(menuItem, index$1,
// @ts-ignore - not a big deal because it is internally creating an event but ask help
event, updateListAndMenu);
}
return /*#__PURE__*/jsxRuntime.jsx(index.ActionList.LinkItem, {
sx: styles.menuItemStyles,
onClick: event => {
// When there are no items in the list, do not run the swap function as we want to keep everything in the menu.
!onlyMenuVisible && swapMenuItemWithListItem(menuItem, index$1, event, updateListAndMenu);
closeOverlay();
focusOnMoreMenuBtn();
// fire onSelect event that comes from the UnderlineNav.Item (if it is defined)
typeof onSelect === 'function' && onSelect(event);
},
...menuItemProps,
children: /*#__PURE__*/jsxRuntime.jsxs(Box, {
as: "span",
sx: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
},
children: [menuItemChildren, loadingCounters ? /*#__PURE__*/jsxRuntime.jsx(UnderlineTabbedInterface.LoadingCounter, {}) : counter !== undefined && /*#__PURE__*/jsxRuntime.jsx(Box, {
as: "span",
"data-component": "counter",
children: /*#__PURE__*/jsxRuntime.jsx(CounterLabel, {
children: counter
})
})]
})
}, menuItemChildren);
})
})]
})]
})
})]
});
});
UnderlineNav.displayName = 'UnderlineNav';
exports.MORE_BTN_WIDTH = MORE_BTN_WIDTH;
exports.MoreMenuListItem = MoreMenuListItem;
exports.UnderlineNav = UnderlineNav;
exports.getValidChildren = getValidChildren;