@primer/react
Version:
An implementation of GitHub's Primer Design System using React
357 lines (348 loc) • 16.7 kB
JavaScript
import React__default, { forwardRef, useRef, useState, useEffect, useCallback } from 'react';
import sx from '../sx.js';
import { UnderlineNavContext } from './UnderlineNavContext.js';
import { useResizeObserver } from '../hooks/useResizeObserver.js';
import { useTheme } from '../ThemeProvider.js';
import VisuallyHidden from '../_VisuallyHidden.js';
import { getNavStyles, ulStyles, getDividerStyle, moreBtnStyles, menuStyles, menuItemStyles, GAP } from './styles.js';
import styled from 'styled-components';
import { LoadingCounter } from './LoadingCounter.js';
import { Button } from '../Button/index.js';
import { TriangleDownIcon } from '@primer/octicons-react';
import { useOnEscapePress } from '../hooks/useOnEscapePress.js';
import { useOnOutsideClick } from '../hooks/useOnOutsideClick.js';
import { useId } from '../hooks/useId.js';
import { ActionList } from '../ActionList/index.js';
import { defaultSxProp } from '../utils/defaultSxProp.js';
import Box from '../Box/Box.js';
import CounterLabel from '../CounterLabel/CounterLabel.js';
import merge from 'deepmerge';
function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
// 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
const NavigationList = styled.ul.withConfig({
displayName: "UnderlineNav__NavigationList",
componentId: "sc-3wwkh2-0"
})(["", ";"], sx);
const MoreMenuListItem = styled.li.withConfig({
displayName: "UnderlineNav__MoreMenuListItem",
componentId: "sc-3wwkh2-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,
actions: []
}, 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 actions = [];
// 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 accessibiility 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];
actions.push(propsectiveAction);
} else {
actions.push(child);
}
}
}
}
updateListAndMenu({
items,
actions
}, iconsVisible);
};
const getValidChildren = children => {
return React__default.Children.toArray(children).filter(child => /*#__PURE__*/React__default.isValidElement(child));
};
const calculatePossibleItems = (childWidthArray, navWidth, moreMenuWidth = 0) => {
const widthToFit = navWidth - moreMenuWidth;
let breakpoint = childWidthArray.length - 1;
let sumsOfChildWidth = 0;
for (const [index, childWidth] of childWidthArray.entries()) {
if (sumsOfChildWidth > widthToFit) {
breakpoint = index - 1;
break;
} else {
// The the gap between items into account when calculating the number of items possible
sumsOfChildWidth = sumsOfChildWidth + childWidth.width + GAP;
}
}
return breakpoint;
};
const UnderlineNav = /*#__PURE__*/forwardRef(({
as = 'nav',
align,
'aria-label': ariaLabel,
sx: sxProp = defaultSxProp,
afterSelect,
variant = 'default',
loadingCounters = false,
children
}, forwardedRef) => {
const backupRef = useRef(null);
const navRef = forwardedRef !== null && forwardedRef !== void 0 ? forwardedRef : backupRef;
const listRef = useRef(null);
const moreMenuRef = useRef(null);
const moreMenuBtnRef = useRef(null);
const containerRef = React__default.useRef(null);
const disclosureWidgetId = useId();
const {
theme
} = useTheme();
function getItemsWidth(itemText) {
var _noIconChildWidthArra;
return ((_noIconChildWidthArra = noIconChildWidthArray.find(item => item.text === itemText)) === null || _noIconChildWidthArra === void 0 ? void 0 : _noIconChildWidthArra.width) || 0;
}
const swapMenuItemWithListItem = (prospectiveListItem, indexOfProspectiveListItem, event, callback) => {
var _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 = listRef.current) === null || _listRef$current === void 0 ? void 0 : _listRef$current.getBoundingClientRect().width) || 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 = [...actions];
// 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);
setSelectedLinkText(prospectiveListItem.props.children);
callback({
items: updatedItemList,
actions: 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 [selectedLink, setSelectedLink] = useState(undefined);
// selectedLinkText is needed to be able set the selected menu item as selectedLink.
// This is needed because setSelectedLink only accepts ref but at the time of setting selected menu item as selectedLink, its ref as a list item is not available
const [selectedLinkText, setSelectedLinkText] = useState('');
// Capture the mouse/keyboard event when a menu item is selected so that we can use it to fire the onSelect callback after the menu item is swapped with the list item
const [selectEvent, setSelectEvent] = useState(null);
const [iconsVisible, setIconsVisible] = useState(true);
const afterSelectHandler = event => {
if (!event.defaultPrevented) {
if (typeof afterSelect === 'function') afterSelect(event);
closeOverlay();
}
};
const [responsiveProps, setResponsiveProps] = useState({
items: getValidChildren(children),
actions: []
});
/*
* This is needed to make sure responsiveProps.items and ResponsiveProps.actions are updated when children are changed
* Particually when an item is selected. It adds 'aria-current="page"' attribute to the child and we need to make sure
* responsiveProps.items and ResponsiveProps.actions are updated with that attribute
*/
useEffect(() => {
const childArray = getValidChildren(children);
const updatedItems = responsiveProps.items.map(item => {
return childArray.find(child => child.key === item.key) || item;
});
const updatedActions = responsiveProps.actions.map(action => {
return childArray.find(child => child.key === action.key) || action;
});
setResponsiveProps({
items: updatedItems,
actions: updatedActions
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [children]);
const updateListAndMenu = useCallback((props, displayIcons) => {
setResponsiveProps(props);
setIconsVisible(displayIcons);
}, []);
const actions = responsiveProps.actions;
// 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;
const [childWidthArray, setChildWidthArray] = useState([]);
const setChildrenWidth = useCallback(size => {
setChildWidthArray(arr => {
const newArr = [...arr, size];
return newArr;
});
}, []);
const [noIconChildWidthArray, setNoIconChildWidthArray] = useState([]);
const setNoIconChildrenWidth = useCallback(size => {
setNoIconChildWidthArray(arr => {
const newArr = [...arr, size];
return newArr;
});
}, []);
useResizeObserver(resizeObserverEntries => {
var _moreMenuRef$current$, _moreMenuRef$current;
const childArray = getValidChildren(children);
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, childArray, childWidthArray, noIconChildWidthArray, updateListAndMenu);
}, navRef);
if (!ariaLabel) {
// eslint-disable-next-line no-console
console.warn('Use the `aria-label` prop to provide an accessible label for assistive technology');
}
const [isWidgetOpen, setIsWidgetOpen] = useState(false);
const closeOverlay = React__default.useCallback(() => {
setIsWidgetOpen(false);
}, [setIsWidgetOpen]);
const focusOnMoreMenuBtn = React__default.useCallback(() => {
var _moreMenuBtnRef$curre;
(_moreMenuBtnRef$curre = moreMenuBtnRef.current) === null || _moreMenuBtnRef$curre === void 0 ? void 0 : _moreMenuBtnRef$curre.focus();
}, []);
useOnEscapePress(event => {
if (isWidgetOpen) {
event.preventDefault();
closeOverlay();
focusOnMoreMenuBtn();
}
}, [isWidgetOpen]);
useOnOutsideClick({
onClickOutside: closeOverlay,
containerRef,
ignoreClickRefs: [moreMenuBtnRef]
});
const onAnchorClick = useCallback(event => {
if (event.defaultPrevented || event.button !== 0) {
return;
}
setIsWidgetOpen(isWidgetOpen => !isWidgetOpen);
}, []);
return /*#__PURE__*/React__default.createElement(UnderlineNavContext.Provider, {
value: {
theme,
setChildrenWidth,
setNoIconChildrenWidth,
selectedLink,
setSelectedLink,
selectedLinkText,
setSelectedLinkText,
selectEvent,
afterSelect: afterSelectHandler,
variant,
loadingCounters,
iconsVisible
}
}, ariaLabel && /*#__PURE__*/React__default.createElement(VisuallyHidden, {
as: "h2"
}, `${ariaLabel} navigation`), /*#__PURE__*/React__default.createElement(Box, {
as: as,
sx: merge(getNavStyles(theme, {
align
}), sxProp),
"aria-label": ariaLabel,
ref: navRef
}, /*#__PURE__*/React__default.createElement(NavigationList, {
sx: ulStyles,
ref: listRef,
role: "list"
}, responsiveProps.items, actions.length > 0 && /*#__PURE__*/React__default.createElement(MoreMenuListItem, {
ref: moreMenuRef
}, !onlyMenuVisible && /*#__PURE__*/React__default.createElement(Box, {
sx: getDividerStyle(theme)
}), /*#__PURE__*/React__default.createElement(Button, {
ref: moreMenuBtnRef,
sx: moreBtnStyles,
"aria-controls": disclosureWidgetId,
"aria-expanded": isWidgetOpen,
onClick: onAnchorClick,
trailingAction: TriangleDownIcon
}, /*#__PURE__*/React__default.createElement(Box, {
as: "span"
}, onlyMenuVisible ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(VisuallyHidden, {
as: "span"
}, `${ariaLabel}`, "\xA0"), "Menu") : /*#__PURE__*/React__default.createElement(React__default.Fragment, null, "More", /*#__PURE__*/React__default.createElement(VisuallyHidden, {
as: "span"
}, "\xA0", `${ariaLabel} items`)))), /*#__PURE__*/React__default.createElement(ActionList, {
selectionVariant: "single",
ref: containerRef,
id: disclosureWidgetId,
sx: menuStyles,
style: {
display: isWidgetOpen ? 'block' : 'none'
}
}, actions.map((action, index) => {
const {
children: actionElementChildren,
...actionElementProps
} = action.props;
return /*#__PURE__*/React__default.createElement(Box, {
key: actionElementChildren,
as: "li"
}, /*#__PURE__*/React__default.createElement(ActionList.Item, _extends({}, actionElementProps, {
as: action.props.as || 'a',
sx: menuItemStyles,
onSelect: 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(action, index, event, updateListAndMenu);
setSelectEvent(event);
closeOverlay();
focusOnMoreMenuBtn();
}
}), /*#__PURE__*/React__default.createElement(Box, {
as: "span",
sx: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}
}, actionElementChildren, loadingCounters ? /*#__PURE__*/React__default.createElement(LoadingCounter, null) : actionElementProps.counter !== undefined && /*#__PURE__*/React__default.createElement(Box, {
as: "span",
"data-component": "counter"
}, /*#__PURE__*/React__default.createElement(CounterLabel, null, actionElementProps.counter)))));
}))))));
});
UnderlineNav.displayName = 'UnderlineNav';
export { UnderlineNav };