UNPKG

@rc-component/menu

Version:
443 lines (420 loc) 14.5 kB
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); } import { clsx } from 'clsx'; import Overflow from '@rc-component/overflow'; import useControlledState from "@rc-component/util/es/hooks/useControlledState"; import useId from "@rc-component/util/es/hooks/useId"; import isEqual from "@rc-component/util/es/isEqual"; import warning from "@rc-component/util/es/warning"; import * as React from 'react'; import { useImperativeHandle } from 'react'; import { flushSync } from 'react-dom'; import { IdContext } from "./context/IdContext"; import MenuContextProvider from "./context/MenuContext"; import { PathRegisterContext, PathUserContext } from "./context/PathContext"; import PrivateContext from "./context/PrivateContext"; import { getFocusableElements, refreshElements, useAccessibility } from "./hooks/useAccessibility"; import useKeyRecords, { OVERFLOW_KEY } from "./hooks/useKeyRecords"; import useMemoCallback from "./hooks/useMemoCallback"; import MenuItem from "./MenuItem"; import SubMenu from "./SubMenu"; import { parseItems } from "./utils/nodeUtil"; import { warnItemProp } from "./utils/warnUtil"; /** * Menu modify after refactor: * ## Add * - disabled * * ## Remove * - openTransitionName * - openAnimation * - onDestroy * - siderCollapsed: Seems antd do not use this prop (Need test in antd) * - collapsedWidth: Seems this logic should be handle by antd Layout.Sider */ // optimize for render const EMPTY_LIST = []; const Menu = /*#__PURE__*/React.forwardRef((props, ref) => { const { prefixCls = 'rc-menu', rootClassName, style, className, styles, classNames: menuClassNames, tabIndex = 0, items, children, direction, id, // Mode mode = 'vertical', inlineCollapsed, // Disabled disabled, disabledOverflow, // Open subMenuOpenDelay = 0.1, subMenuCloseDelay = 0.1, forceSubMenuRender, defaultOpenKeys, openKeys, // Active activeKey, defaultActiveFirst, // Selection selectable = true, multiple = false, defaultSelectedKeys, selectedKeys, onSelect, onDeselect, // Level inlineIndent = 24, // Motion motion, defaultMotions, // Popup triggerSubMenuAction = 'hover', builtinPlacements, // Icon itemIcon, expandIcon, overflowedIndicator = '...', overflowedIndicatorPopupClassName, // Function getPopupContainer, // Events onClick, onOpenChange, onKeyDown, // Deprecated openAnimation, openTransitionName, // Internal _internalRenderMenuItem, _internalRenderSubMenuItem, _internalComponents, popupRender, ...restProps } = props; const [childList, measureChildList] = React.useMemo(() => [parseItems(children, items, EMPTY_LIST, _internalComponents, prefixCls), parseItems(children, items, EMPTY_LIST, {}, prefixCls)], [children, items, _internalComponents]); const [mounted, setMounted] = React.useState(false); const containerRef = React.useRef(); const uuid = useId(id ? `rc-menu-uuid-${id}` : 'rc-menu-uuid'); const isRtl = direction === 'rtl'; // ========================= Warn ========================= if (process.env.NODE_ENV !== 'production') { warning(!openAnimation && !openTransitionName, '`openAnimation` and `openTransitionName` is removed. Please use `motion` or `defaultMotion` instead.'); } // ========================= Open ========================= const [innerOpenKeys, setMergedOpenKeys] = useControlledState(defaultOpenKeys, openKeys); const mergedOpenKeys = innerOpenKeys || EMPTY_LIST; // React 18 will merge mouse event which means we open key will not sync // ref: https://github.com/ant-design/ant-design/issues/38818 const triggerOpenKeys = (keys, forceFlush = false) => { function doUpdate() { setMergedOpenKeys(keys); onOpenChange?.(keys); } if (forceFlush) { flushSync(doUpdate); } else { doUpdate(); } }; // >>>>> Cache & Reset open keys when inlineCollapsed changed const [inlineCacheOpenKeys, setInlineCacheOpenKeys] = React.useState(mergedOpenKeys); const mountRef = React.useRef(false); // ========================= Mode ========================= const [mergedMode, mergedInlineCollapsed] = React.useMemo(() => { if ((mode === 'inline' || mode === 'vertical') && inlineCollapsed) { return ['vertical', inlineCollapsed]; } return [mode, false]; }, [mode, inlineCollapsed]); const isInlineMode = mergedMode === 'inline'; const [internalMode, setInternalMode] = React.useState(mergedMode); const [internalInlineCollapsed, setInternalInlineCollapsed] = React.useState(mergedInlineCollapsed); React.useEffect(() => { setInternalMode(mergedMode); setInternalInlineCollapsed(mergedInlineCollapsed); if (!mountRef.current) { return; } // Synchronously update MergedOpenKeys if (isInlineMode) { setMergedOpenKeys(inlineCacheOpenKeys); } else { // Trigger open event in case its in control triggerOpenKeys(EMPTY_LIST); } }, [mergedMode, mergedInlineCollapsed]); // ====================== Responsive ====================== const [lastVisibleIndex, setLastVisibleIndex] = React.useState(0); const allVisible = lastVisibleIndex >= childList.length - 1 || internalMode !== 'horizontal' || disabledOverflow; // Cache React.useEffect(() => { if (isInlineMode) { setInlineCacheOpenKeys(mergedOpenKeys); } }, [mergedOpenKeys]); React.useEffect(() => { mountRef.current = true; return () => { mountRef.current = false; }; }, []); // ========================= Path ========================= const { registerPath, unregisterPath, refreshOverflowKeys, isSubPathKey, getKeyPath, getKeys, getSubPathKeys } = useKeyRecords(); const registerPathContext = React.useMemo(() => ({ registerPath, unregisterPath }), [registerPath, unregisterPath]); const pathUserContext = React.useMemo(() => ({ isSubPathKey }), [isSubPathKey]); React.useEffect(() => { refreshOverflowKeys(allVisible ? EMPTY_LIST : childList.slice(lastVisibleIndex + 1).map(child => child.key)); }, [lastVisibleIndex, allVisible]); // ======================== Active ======================== const [mergedActiveKey, setMergedActiveKey] = useControlledState(activeKey || defaultActiveFirst && childList[0]?.key, activeKey); const onActive = useMemoCallback(key => { setMergedActiveKey(key); }); const onInactive = useMemoCallback(() => { setMergedActiveKey(undefined); }); useImperativeHandle(ref, () => { return { list: containerRef.current, focus: options => { const keys = getKeys(); const { elements, key2element, element2key } = refreshElements(keys, uuid); const focusableElements = getFocusableElements(containerRef.current, elements); let shouldFocusKey; if (mergedActiveKey && keys.includes(mergedActiveKey)) { shouldFocusKey = mergedActiveKey; } else { shouldFocusKey = focusableElements[0] ? element2key.get(focusableElements[0]) : childList.find(node => !node.props.disabled)?.key; } const elementToFocus = key2element.get(shouldFocusKey); if (shouldFocusKey && elementToFocus) { elementToFocus?.focus?.(options); } }, findItem: ({ key: itemKey }) => { const keys = getKeys(); const { key2element } = refreshElements(keys, uuid); return key2element.get(itemKey) || null; } }; }); // ======================== Select ======================== // >>>>> Select keys const [internalSelectKeys, setMergedSelectKeys] = useControlledState(defaultSelectedKeys || [], selectedKeys); const mergedSelectKeys = React.useMemo(() => { if (Array.isArray(internalSelectKeys)) { return internalSelectKeys; } if (internalSelectKeys === null || internalSelectKeys === undefined) { return EMPTY_LIST; } return [internalSelectKeys]; }, [internalSelectKeys]); // >>>>> Trigger select const triggerSelection = info => { if (selectable) { // Insert or Remove const { key: targetKey } = info; const exist = mergedSelectKeys.includes(targetKey); let newSelectKeys; if (multiple) { if (exist) { newSelectKeys = mergedSelectKeys.filter(key => key !== targetKey); } else { newSelectKeys = [...mergedSelectKeys, targetKey]; } } else { newSelectKeys = [targetKey]; } setMergedSelectKeys(newSelectKeys); // Trigger event const selectInfo = { ...info, selectedKeys: newSelectKeys }; if (exist) { onDeselect?.(selectInfo); } else { onSelect?.(selectInfo); } } // Whatever selectable, always close it if (!multiple && mergedOpenKeys.length && internalMode !== 'inline') { triggerOpenKeys(EMPTY_LIST); } }; // ========================= Open ========================= /** * Click for item. SubMenu do not have selection status */ const onInternalClick = useMemoCallback(info => { onClick?.(warnItemProp(info)); triggerSelection(info); }); const onInternalOpenChange = useMemoCallback((key, open) => { let newOpenKeys = mergedOpenKeys.filter(k => k !== key); if (open) { newOpenKeys.push(key); } else if (internalMode !== 'inline') { // We need find all related popup to close const subPathKeys = getSubPathKeys(key); newOpenKeys = newOpenKeys.filter(k => !subPathKeys.has(k)); } if (!isEqual(mergedOpenKeys, newOpenKeys, true)) { triggerOpenKeys(newOpenKeys, true); } }); // ==================== Accessibility ===================== const triggerAccessibilityOpen = (key, open) => { const nextOpen = open ?? !mergedOpenKeys.includes(key); onInternalOpenChange(key, nextOpen); }; const onInternalKeyDown = useAccessibility(internalMode, mergedActiveKey, isRtl, uuid, containerRef, getKeys, getKeyPath, setMergedActiveKey, triggerAccessibilityOpen, onKeyDown); // ======================== Effect ======================== React.useEffect(() => { setMounted(true); }, []); // ======================= Context ======================== const privateContext = React.useMemo(() => ({ _internalRenderMenuItem, _internalRenderSubMenuItem }), [_internalRenderMenuItem, _internalRenderSubMenuItem]); // ======================== Render ======================== // >>>>> Children const wrappedChildList = internalMode !== 'horizontal' || disabledOverflow ? childList : // Need wrap for overflow dropdown that do not response for open childList.map((child, index) => /*#__PURE__*/ // Always wrap provider to avoid sub node re-mount React.createElement(MenuContextProvider, { key: child.key, overflowDisabled: index > lastVisibleIndex, classNames: menuClassNames, styles: styles }, child)); // >>>>> Container const container = /*#__PURE__*/React.createElement(Overflow, _extends({ id: id, ref: containerRef, prefixCls: `${prefixCls}-overflow`, component: "ul", itemComponent: MenuItem, className: clsx(prefixCls, `${prefixCls}-root`, `${prefixCls}-${internalMode}`, className, { [`${prefixCls}-inline-collapsed`]: internalInlineCollapsed, [`${prefixCls}-rtl`]: isRtl }, rootClassName), dir: direction, style: style, role: "menu", tabIndex: tabIndex, data: wrappedChildList, renderRawItem: node => node, renderRawRest: omitItems => { // We use origin list since wrapped list use context to prevent open const len = omitItems.length; const originOmitItems = len ? childList.slice(-len) : null; return /*#__PURE__*/React.createElement(SubMenu, { eventKey: OVERFLOW_KEY, title: overflowedIndicator, disabled: allVisible, internalPopupClose: len === 0, popupClassName: overflowedIndicatorPopupClassName }, originOmitItems); }, maxCount: internalMode !== 'horizontal' || disabledOverflow ? Overflow.INVALIDATE : Overflow.RESPONSIVE, ssr: "full", "data-menu-list": true, onVisibleChange: newLastIndex => { setLastVisibleIndex(newLastIndex); }, onKeyDown: onInternalKeyDown }, restProps)); // >>>>> Render return /*#__PURE__*/React.createElement(PrivateContext.Provider, { value: privateContext }, /*#__PURE__*/React.createElement(IdContext.Provider, { value: uuid }, /*#__PURE__*/React.createElement(MenuContextProvider, { prefixCls: prefixCls, rootClassName: rootClassName, classNames: menuClassNames, styles: styles, mode: internalMode, openKeys: mergedOpenKeys, rtl: isRtl // Disabled , disabled: disabled // Motion , motion: mounted ? motion : null, defaultMotions: mounted ? defaultMotions : null // Active , activeKey: mergedActiveKey, onActive: onActive, onInactive: onInactive // Selection , selectedKeys: mergedSelectKeys // Level , inlineIndent: inlineIndent // Popup , subMenuOpenDelay: subMenuOpenDelay, subMenuCloseDelay: subMenuCloseDelay, forceSubMenuRender: forceSubMenuRender, builtinPlacements: builtinPlacements, triggerSubMenuAction: triggerSubMenuAction, getPopupContainer: getPopupContainer // Icon , itemIcon: itemIcon, expandIcon: expandIcon // Events , onItemClick: onInternalClick, onOpenChange: onInternalOpenChange, popupRender: popupRender }, /*#__PURE__*/React.createElement(PathUserContext.Provider, { value: pathUserContext }, container), /*#__PURE__*/React.createElement("div", { style: { display: 'none' }, "aria-hidden": true }, /*#__PURE__*/React.createElement(PathRegisterContext.Provider, { value: registerPathContext }, measureChildList))))); }); export default Menu;