@primer/react
Version:
An implementation of GitHub's Primer Design System using React
355 lines (347 loc) • 11.9 kB
JavaScript
import { c } from 'react-compiler-runtime';
import React, { forwardRef, useState, useCallback, useRef } from 'react';
import { KebabHorizontalIcon } from '@primer/octicons-react';
import { ActionList } from '../ActionList/index.js';
import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect.js';
import { useOnEscapePress } from '../hooks/useOnEscapePress.js';
import { useResizeObserver } from '../hooks/useResizeObserver.js';
import { useOnOutsideClick } from '../hooks/useOnOutsideClick.js';
import { IconButton } from '../Button/IconButton.js';
import { useFocusZone } from '../hooks/useFocusZone.js';
import styles from './ActionBar.module.css.js';
import { clsx } from 'clsx';
import { jsx, jsxs } from 'react/jsx-runtime';
import { FocusKeys } from '@primer/behaviors';
import { ActionMenu } from '../ActionMenu/ActionMenu.js';
const ActionBarContext = /*#__PURE__*/React.createContext({
size: 'medium',
setChildrenWidth: () => null
});
/*
small (28px), medium (32px), large (40px)
*/
const MORE_BTN_WIDTH = 86;
const getValidChildren = children => {
return React.Children.toArray(children).filter(child => {
return /*#__PURE__*/React.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; // + GAP
if (sumsOfChildWidth > widthToFit) {
breakpoint = index;
break;
} else {
continue;
}
}
return breakpoint;
};
const overflowEffect = (navWidth, moreMenuWidth, childArray, childWidthArray, updateListAndMenu, hasActiveMenu) => {
if (childWidthArray.length === 0) {
updateListAndMenu({
items: childArray,
menuItems: []
});
}
const numberOfItemsPossible = calculatePossibleItems(childWidthArray, navWidth);
const numberOfItemsPossibleWithMoreMenu = calculatePossibleItems(childWidthArray, 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) {
/* 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);
//if the last item is a divider
} else if (childWidthArray[index].text === 'divider') {
if (index === numberOfListItems - 1 || index === numberOfListItems) {
continue;
} else {
const divider = /*#__PURE__*/React.createElement(ActionList.Divider, {
key: index
});
menuItems.push(divider);
}
} else {
menuItems.push(child);
}
}
updateListAndMenu({
items,
menuItems
});
} else if (numberOfItemsPossible > childArray.length && hasActiveMenu) {
/* If the items fit in the list and there are items in the overflow menu, we need to move them back to the list */
updateListAndMenu({
items: childArray,
menuItems: []
});
}
};
const ActionBar = props => {
const {
size = 'medium',
children,
'aria-label': ariaLabel,
flush = false,
className
} = props;
const [childWidthArray, setChildWidthArray] = useState([]);
const setChildrenWidth = useCallback(size_0 => {
setChildWidthArray(arr => {
const newArr = [...arr, size_0];
return newArr;
});
}, []);
const navRef = useRef(null);
const listRef = useRef(null);
const moreMenuRef = useRef(null);
const moreMenuBtnRef = useRef(null);
const containerRef = React.useRef(null);
const validChildren = getValidChildren(children);
// Responsive props object manages which items are in the list and which items are in the menu.
const [responsiveProps, setResponsiveProps] = 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_0 => child_0.key === menuItem.key)) !== null && _validChildren$find2 !== void 0 ? _validChildren$find2 : menuItem;
});
const updateListAndMenu = useCallback(props_0 => {
setResponsiveProps(props_0);
}, []);
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;
const hasActiveMenu = menuItems.length > 0;
navWidth !== 0 && overflowEffect(navWidth, moreMenuWidth, validChildren, childWidthArray, updateListAndMenu, hasActiveMenu);
}, navRef);
const [isWidgetOpen, setIsWidgetOpen] = useState(false);
const closeOverlay = React.useCallback(() => {
setIsWidgetOpen(false);
}, [setIsWidgetOpen]);
const focusOnMoreMenuBtn = React.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]
});
useFocusZone({
containerRef: listRef,
bindKeys: FocusKeys.ArrowHorizontal | FocusKeys.HomeAndEnd,
focusOutBehavior: 'wrap'
});
return /*#__PURE__*/jsx(ActionBarContext.Provider, {
value: {
size,
setChildrenWidth
},
children: /*#__PURE__*/jsx("div", {
ref: navRef,
className: clsx(className, styles.Nav),
"data-flush": flush,
children: /*#__PURE__*/jsxs("div", {
ref: listRef,
role: "toolbar",
className: styles.List,
children: [listItems, menuItems.length > 0 && /*#__PURE__*/jsxs(ActionMenu, {
children: [/*#__PURE__*/jsx(ActionMenu.Anchor, {
children: /*#__PURE__*/jsx(IconButton, {
variant: "invisible",
"aria-label": `More ${ariaLabel} items`,
icon: KebabHorizontalIcon
})
}), /*#__PURE__*/jsx(ActionMenu.Overlay, {
children: /*#__PURE__*/jsx(ActionList, {
children: menuItems.map((menuItem_0, index) => {
if (menuItem_0.type === ActionList.Divider) {
return /*#__PURE__*/jsx(ActionList.Divider, {}, index);
} else {
const {
children: menuItemChildren,
onClick,
icon: Icon,
'aria-label': ariaLabel_0,
disabled
} = menuItem_0.props;
return /*#__PURE__*/jsxs(ActionList.Item, {
// eslint-disable-next-line primer-react/prefer-action-list-item-onselect
onClick: event_0 => {
closeOverlay();
focusOnMoreMenuBtn();
typeof onClick === 'function' && onClick(event_0);
},
disabled: disabled,
children: [Icon ? /*#__PURE__*/jsx(ActionList.LeadingVisual, {
children: /*#__PURE__*/jsx(Icon, {})
}) : null, ariaLabel_0]
}, menuItemChildren);
}
})
})
})]
})]
})
})
});
};
ActionBar.displayName = "ActionBar";
const ActionBarIconButton = /*#__PURE__*/forwardRef((t0, forwardedRef) => {
const $ = c(20);
let disabled;
let onClick;
let props;
if ($[0] !== t0) {
({
disabled,
onClick,
...props
} = t0);
$[0] = t0;
$[1] = disabled;
$[2] = onClick;
$[3] = props;
} else {
disabled = $[1];
onClick = $[2];
props = $[3];
}
const backupRef = useRef(null);
const ref = forwardedRef !== null && forwardedRef !== void 0 ? forwardedRef : backupRef;
const {
size,
setChildrenWidth
} = React.useContext(ActionBarContext);
let t1;
if ($[4] !== props || $[5] !== ref || $[6] !== setChildrenWidth) {
t1 = () => {
const text = props["aria-label"] ? props["aria-label"] : "";
const domRect = ref.current.getBoundingClientRect();
setChildrenWidth({
text,
width: domRect.width
});
};
$[4] = props;
$[5] = ref;
$[6] = setChildrenWidth;
$[7] = t1;
} else {
t1 = $[7];
}
let t2;
if ($[8] !== ref || $[9] !== setChildrenWidth) {
t2 = [ref, setChildrenWidth];
$[8] = ref;
$[9] = setChildrenWidth;
$[10] = t2;
} else {
t2 = $[10];
}
useIsomorphicLayoutEffect(t1, t2);
let t3;
if ($[11] !== disabled || $[12] !== onClick) {
t3 = event => {
var _onClick;
if (disabled) {
return;
}
(_onClick = onClick) === null || _onClick === void 0 ? void 0 : _onClick(event);
};
$[11] = disabled;
$[12] = onClick;
$[13] = t3;
} else {
t3 = $[13];
}
const clickHandler = t3;
let t4;
if ($[14] !== clickHandler || $[15] !== disabled || $[16] !== props || $[17] !== ref || $[18] !== size) {
t4 = /*#__PURE__*/jsx(IconButton, {
"aria-disabled": disabled,
ref: ref,
size: size,
onClick: clickHandler,
...props,
variant: "invisible"
});
$[14] = clickHandler;
$[15] = disabled;
$[16] = props;
$[17] = ref;
$[18] = size;
$[19] = t4;
} else {
t4 = $[19];
}
return t4;
});
const VerticalDivider = () => {
const $ = c(4);
const ref = useRef(null);
const {
setChildrenWidth
} = React.useContext(ActionBarContext);
let t0;
let t1;
if ($[0] !== setChildrenWidth) {
t0 = () => {
const domRect = ref.current.getBoundingClientRect();
setChildrenWidth({
text: "divider",
width: domRect.width
});
};
t1 = [ref, setChildrenWidth];
$[0] = setChildrenWidth;
$[1] = t0;
$[2] = t1;
} else {
t0 = $[1];
t1 = $[2];
}
useIsomorphicLayoutEffect(t0, t1);
let t2;
if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
t2 = /*#__PURE__*/jsx("div", {
ref: ref,
"data-component": "ActionBar.VerticalDivider",
"aria-hidden": "true",
className: styles.Divider
});
$[3] = t2;
} else {
t2 = $[3];
}
return t2;
};
export { ActionBar, ActionBarIconButton, VerticalDivider };