@itwin/itwinui-react
Version:
A react component library for iTwinUI
236 lines (235 loc) • 6.45 kB
JavaScript
import * as React from 'react';
import cx from 'classnames';
import {
useMergedRefs,
Box,
Portal,
useControlledState,
cloneElementWithRef,
mergeRefs,
useSyncExternalStore,
mergeEventHandlers,
useFocusableElements,
} from '../../utils/index.js';
import { PopoverOpenContext, usePopover } from '../Popover/Popover.js';
import {
FloatingNode,
useFloatingNodeId,
useFloatingParentNodeId,
useFloatingTree,
useListNavigation,
useInteractions,
} from '@floating-ui/react';
export const Menu = React.forwardRef((props, ref) => {
let {
className,
trigger,
positionReference,
portal: portalProp,
popoverProps: popoverPropsProp,
children,
...rest
} = props;
let menuPortalContext = React.useContext(MenuPortalContext);
let portal = portalProp ?? menuPortalContext;
let tree = useFloatingTree();
let nodeId = useFloatingNodeId();
let parentId = useFloatingParentNodeId();
let {
interactions: interactionsProp,
visible: visibleProp,
onVisibleChange: onVisibleChangeProp,
...restPopoverProps
} = popoverPropsProp ?? {};
let {
listNavigation: listNavigationPropsProp,
hover: hoverProp,
...restInteractionsProps
} = interactionsProp ?? {};
let [visible, setVisible] = useControlledState(
false,
visibleProp,
onVisibleChangeProp,
);
let [hasFocusedNodeInSubmenu, setHasFocusedNodeInSubmenu] =
React.useState(false);
let [menuElement, setMenuElement] = React.useState(null);
let { focusableElementsRef, focusableElements } = useFocusableElements(
menuElement,
{
filter: (allElements) =>
allElements.filter(
(i) => !allElements?.some((p) => p.contains(i.parentElement)),
),
},
);
let [activeIndex, setActiveIndex] = React.useState(null);
let popover = usePopover({
nodeId,
visible,
onVisibleChange: (open) => (open ? setVisible(true) : close()),
interactions: {
hover:
null == tree
? hoverProp
: {
enabled: !!hoverProp && !hasFocusedNodeInSubmenu,
...hoverProp,
},
...restInteractionsProps,
},
...restPopoverProps,
middleware: {
size: {
maxHeight: 'var(--iui-menu-max-height)',
},
...restPopoverProps.middleware,
},
});
let { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
useListNavigation(popover.context, {
activeIndex,
focusItemOnHover: false,
listRef: focusableElementsRef,
onNavigate: setActiveIndex,
...listNavigationPropsProp,
}),
]);
React.useEffect(() => {
if (void 0 !== positionReference)
popover.refs.setPositionReference(positionReference);
}, [popover.refs, positionReference]);
let refs = useMergedRefs(setMenuElement, ref, popover.refs.setFloating);
let triggerRef = React.useRef(null);
let close = React.useCallback(() => {
setVisible(false);
if (null == parentId)
triggerRef.current?.focus({
preventScroll: true,
});
}, [parentId, setVisible]);
useSyncExternalStore(
React.useCallback(() => {
let closeUnrelatedMenus = (event) => {
if (
(parentId === event.parentId && nodeId !== event.nodeId) ||
parentId === event.nodeId
) {
setVisible(false);
setHasFocusedNodeInSubmenu(false);
}
};
tree?.events.on('onNodeFocused', closeUnrelatedMenus);
return () => {
tree?.events.off('onNodeFocused', closeUnrelatedMenus);
};
}, [nodeId, parentId, tree?.events, setVisible]),
() => void 0,
() => void 0,
);
let popoverGetItemProps = React.useCallback(
({ focusableItemIndex, userProps }) =>
getItemProps({
...userProps,
tabIndex:
null != activeIndex &&
activeIndex >= 0 &&
null != focusableItemIndex &&
focusableItemIndex >= 0 &&
activeIndex === focusableItemIndex
? 0
: -1,
onFocus: mergeEventHandlers(userProps?.onFocus, () => {
queueMicrotask(() => {
setHasFocusedNodeInSubmenu(true);
});
tree?.events.emit('onNodeFocused', {
nodeId: nodeId,
parentId: parentId,
});
}),
onMouseEnter: mergeEventHandlers(userProps?.onMouseEnter, (event) => {
if (null != focusableItemIndex && focusableItemIndex >= 0)
setActiveIndex(focusableItemIndex);
if (event.target === event.currentTarget)
event.currentTarget.focus({
focusVisible: false,
});
}),
}),
[activeIndex, getItemProps, nodeId, parentId, tree?.events],
);
let reference = cloneElementWithRef(trigger, (triggerChild) =>
getReferenceProps(
popover.getReferenceProps({
'aria-haspopup': 'menu',
...triggerChild.props,
'aria-expanded': popover.open,
ref: mergeRefs(triggerRef, popover.refs.setReference),
}),
),
);
let floating =
popover.open &&
React.createElement(
Portal,
{
portal: portal,
},
React.createElement(
Box,
{
as: 'div',
className: cx('iui-menu', className),
ref: refs,
...getFloatingProps(
popover.getFloatingProps({
role: 'menu',
...rest,
}),
),
},
children,
),
);
return React.createElement(
React.Fragment,
null,
React.createElement(
MenuContext.Provider,
{
value: React.useMemo(
() => ({
popoverGetItemProps,
focusableElements,
}),
[focusableElements, popoverGetItemProps],
),
},
React.createElement(
MenuPortalContext.Provider,
{
value: portal,
},
React.createElement(
PopoverOpenContext.Provider,
{
value: popover.open,
},
reference,
),
null != tree
? React.createElement(
FloatingNode,
{
id: nodeId,
},
floating,
)
: floating,
),
),
);
});
export const MenuContext = React.createContext(void 0);
export const MenuPortalContext = React.createContext(void 0);