├── .husky └── pre-commit ├── bunfig.toml ├── index.js ├── docs ├── changelog.md ├── demo │ ├── antd.md │ ├── debug.md │ ├── items.md │ ├── keyPath.md │ ├── single.md │ ├── fragment.md │ ├── multiple.md │ ├── openKeys.md │ ├── rtl-antd.md │ ├── items-ref.md │ ├── scrollable.md │ ├── antd-switch.md │ ├── custom-icon.md │ ├── selectedKeys.md │ ├── menuItemGroup.md │ ├── inlineCollapsed.md │ └── customPopupRender.md ├── index.md └── examples │ ├── inlineCollapsed.less │ ├── scrollable.tsx │ ├── menuItemGroup.tsx │ ├── fragment.tsx │ ├── antd-switch.tsx │ ├── keyPath.tsx │ ├── single.tsx │ ├── inlineCollapsed.tsx │ ├── openKeys.tsx │ ├── customPopupRender.less │ ├── items.tsx │ ├── multiple.tsx │ ├── items-ref.tsx │ ├── selectedKeys.tsx │ ├── custom-icon.tsx │ ├── customPopupRender.tsx │ ├── debug.tsx │ ├── rtl-antd.tsx │ └── antd.tsx ├── tests ├── setupFilesAfterEnv.ts ├── __mocks__ │ └── @rc-component │ │ └── trigger.js ├── setup.js ├── util.ts ├── Options.spec.tsx ├── Private.spec.tsx ├── __snapshots__ │ ├── Keyboard.spec.tsx.snap │ ├── Responsive.spec.tsx.snap │ ├── Options.spec.tsx.snap │ ├── SubMenu.spec.tsx.snap │ └── MenuItem.spec.tsx.snap ├── React18.spec.tsx ├── popupRender.test.tsx ├── semantic.spec.tsx ├── Responsive.spec.tsx ├── Focus.spec.tsx ├── MenuItem.spec.tsx ├── Keyboard.spec.tsx └── Collapsed.spec.tsx ├── type.d.ts ├── .fatherrc.js ├── src ├── utils │ ├── timeUtil.ts │ ├── motionUtil.ts │ ├── warnUtil.ts │ ├── commonUtil.ts │ └── nodeUtil.tsx ├── context │ ├── IdContext.ts │ ├── PrivateContext.ts │ ├── PathContext.tsx │ └── MenuContext.tsx ├── hooks │ ├── useDirectionStyle.ts │ ├── useMemoCallback.ts │ ├── useActive.ts │ ├── useKeyRecords.ts │ └── useAccessibility.ts ├── Divider.tsx ├── Icon.tsx ├── SubMenu │ ├── SubMenuList.tsx │ ├── InlineSubMenuList.tsx │ ├── PopupTrigger.tsx │ └── index.tsx ├── index.ts ├── placements.ts ├── MenuItemGroup.tsx ├── interface.ts └── MenuItem.tsx ├── jest.config.js ├── .github ├── workflows │ ├── main.yml │ └── codeql.yml ├── dependabot.yml └── FUNDING.yml ├── .prettierrc ├── now.json ├── .editorconfig ├── assets ├── menu.less └── index.less ├── tsconfig.json ├── .gitignore ├── .dumirc.ts ├── .eslintrc.js ├── LICENSE.md ├── package.json └── CHANGELOG.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged 2 | -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [install] 2 | peer = false -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src/'); 2 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/setupFilesAfterEnv.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /type.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | 3 | declare module '*.less'; -------------------------------------------------------------------------------- /docs/demo/antd.md: -------------------------------------------------------------------------------- 1 | ## antd 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/demo/debug.md: -------------------------------------------------------------------------------- 1 | ## debug 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/demo/items.md: -------------------------------------------------------------------------------- 1 | ## items 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/demo/keyPath.md: -------------------------------------------------------------------------------- 1 | ## keyPath 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/demo/single.md: -------------------------------------------------------------------------------- 1 | ## single 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/demo/fragment.md: -------------------------------------------------------------------------------- 1 | ## fragment 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/demo/multiple.md: -------------------------------------------------------------------------------- 1 | ## multiple 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/demo/openKeys.md: -------------------------------------------------------------------------------- 1 | ## openKeys 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/demo/rtl-antd.md: -------------------------------------------------------------------------------- 1 | ## rtl-antd 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: rc-menu 3 | --- 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/demo/items-ref.md: -------------------------------------------------------------------------------- 1 | ## items-ref 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/demo/scrollable.md: -------------------------------------------------------------------------------- 1 | ## scrollable 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/demo/antd-switch.md: -------------------------------------------------------------------------------- 1 | ## antd-switch 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/demo/custom-icon.md: -------------------------------------------------------------------------------- 1 | ## custom-icon 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/demo/selectedKeys.md: -------------------------------------------------------------------------------- 1 | ## selectedKeys 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/demo/menuItemGroup.md: -------------------------------------------------------------------------------- 1 | ## menuItemGroup 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/demo/inlineCollapsed.md: -------------------------------------------------------------------------------- 1 | ## inlineCollapsed 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/demo/customPopupRender.md: -------------------------------------------------------------------------------- 1 | ## customPopupRender 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/__mocks__/@rc-component/trigger.js: -------------------------------------------------------------------------------- 1 | import Trigger from '@rc-component/trigger/lib/mock'; 2 | 3 | export default Trigger; 4 | -------------------------------------------------------------------------------- /.fatherrc.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'father'; 2 | 3 | export default defineConfig({ 4 | plugins: ['@rc-component/father-plugin'], 5 | }); -------------------------------------------------------------------------------- /src/utils/timeUtil.ts: -------------------------------------------------------------------------------- 1 | export function nextSlice(callback: () => void) { 2 | /* istanbul ignore next */ 3 | Promise.resolve().then(callback); 4 | } 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFiles: ['/tests/setup.js'], 3 | setupFilesAfterEnv: ['/tests/setupFilesAfterEnv.ts'] 4 | }; 5 | -------------------------------------------------------------------------------- /docs/examples/inlineCollapsed.less: -------------------------------------------------------------------------------- 1 | .rc-menu-submenu-title { 2 | transition: all .3s; 3 | } 4 | 5 | .collapsed .rc-menu-submenu-title { 6 | padding-left: 40px !important; 7 | } -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ✅ test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | uses: react-component/rc-test/.github/workflows/test.yml@main 6 | secrets: inherit -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | window.requestAnimationFrame = func => { 2 | const id = window.setTimeout(func, 16); 3 | return id; 4 | }; 5 | window.cancelAnimationFrame = id => window.clearTimeout(id); 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "printWidth": 100, 7 | "proseWrap": "never", 8 | "trailingComma": "all", 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "rc-menu", 4 | "builds": [ 5 | { 6 | "src": "package.json", 7 | "use": "@now/static-build", 8 | "config": { "distDir": ".doc" } 9 | } 10 | ], 11 | "routes": [ 12 | { "src": "/(.*)", "dest": "/dist/$1" } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | 18 | -------------------------------------------------------------------------------- /assets/menu.less: -------------------------------------------------------------------------------- 1 | @menuPrefixCls: ~'rc-menu'; 2 | 3 | .@{menuPrefixCls} { 4 | &:focus-visible { 5 | box-shadow: 0 0 5px green; 6 | } 7 | 8 | &-horizontal { 9 | display: flex; 10 | flex-wrap: nowrap; 11 | } 12 | 13 | &-submenu { 14 | &-hidden { 15 | display: none; 16 | } 17 | } 18 | 19 | &-overflow-item { 20 | flex: none; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "moduleResolution": "node", 5 | "baseUrl": "./", 6 | "jsx": "react", 7 | "declaration": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "paths": { 11 | "@/*": ["src/*"], 12 | "@@/*": [".dumi/tmp/*"], 13 | "@rc-component/menu": ["src/index.ts"] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/context/IdContext.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export const IdContext = React.createContext(null); 4 | 5 | export function getMenuId(uuid: string, eventKey: string) { 6 | return `${uuid}-${eventKey}`; 7 | } 8 | 9 | /** 10 | * Get `data-menu-id` 11 | */ 12 | export function useMenuId(eventKey: string) { 13 | const id = React.useContext(IdContext); 14 | return getMenuId(id, eventKey); 15 | } 16 | -------------------------------------------------------------------------------- /src/context/PrivateContext.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { MenuProps } from '../Menu'; 3 | 4 | export interface PrivateContextProps { 5 | _internalRenderMenuItem?: MenuProps['_internalRenderMenuItem']; 6 | _internalRenderSubMenuItem?: MenuProps['_internalRenderSubMenuItem']; 7 | } 8 | 9 | const PrivateContext = React.createContext({}); 10 | 11 | export default PrivateContext; 12 | -------------------------------------------------------------------------------- /src/utils/motionUtil.ts: -------------------------------------------------------------------------------- 1 | import type { CSSMotionProps } from '@rc-component/motion'; 2 | 3 | export function getMotion( 4 | mode: string, 5 | motion?: CSSMotionProps, 6 | defaultMotions?: Record, 7 | ) { 8 | if (motion) { 9 | return motion; 10 | } 11 | 12 | if (defaultMotions) { 13 | return defaultMotions[mode] || defaultMotions.other; 14 | } 15 | 16 | return undefined; 17 | } 18 | -------------------------------------------------------------------------------- /tests/util.ts: -------------------------------------------------------------------------------- 1 | export function isActive(container: HTMLElement, index: number, active = true) { 2 | const checker = expect(container.querySelectorAll('li.rc-menu-item')[index]); 3 | 4 | if (active) { 5 | checker.toHaveClass('rc-menu-item-active'); 6 | } else { 7 | checker.not.toHaveClass('rc-menu-item-active'); 8 | } 9 | } 10 | 11 | export function last(elements: NodeListOf) { 12 | return elements[elements.length - 1]; 13 | } 14 | -------------------------------------------------------------------------------- /src/hooks/useDirectionStyle.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { MenuContext } from '../context/MenuContext'; 3 | 4 | export default function useDirectionStyle(level: number): React.CSSProperties { 5 | const { mode, rtl, inlineIndent } = React.useContext(MenuContext); 6 | 7 | if (mode !== 'inline') { 8 | return null; 9 | } 10 | 11 | const len = level; 12 | return rtl 13 | ? { paddingRight: len * inlineIndent } 14 | : { paddingLeft: len * inlineIndent }; 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .storybook 2 | *.iml 3 | *.log 4 | .idea/ 5 | .ipr 6 | .iws 7 | *~ 8 | ~* 9 | *.diff 10 | *.patch 11 | *.bak 12 | .DS_Store 13 | Thumbs.db 14 | .project 15 | .*proj 16 | .svn/ 17 | *.swp 18 | *.swo 19 | *.pyc 20 | *.pyo 21 | .build 22 | node_modules 23 | .cache 24 | dist 25 | assets/**/*.css 26 | build 27 | lib 28 | es 29 | coverage 30 | yarn.lock 31 | package-lock.json 32 | pnpm-lock.yaml 33 | .vscode 34 | 35 | # umi 36 | .dumi/tmp 37 | .dumi/tmp-test 38 | .dumi/tmp-production 39 | .env.local 40 | 41 | bun.lockb -------------------------------------------------------------------------------- /src/hooks/useMemoCallback.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | /** 4 | * Cache callback function that always return same ref instead. 5 | * This is used for context optimization. 6 | */ 7 | export default function useMemoCallback void>( 8 | func: T, 9 | ): T { 10 | const funRef = React.useRef(func); 11 | funRef.current = func; 12 | 13 | const callback = React.useCallback( 14 | ((...args: any[]) => funRef.current?.(...args)) as any, 15 | [], 16 | ); 17 | 18 | return func ? callback : undefined; 19 | } 20 | -------------------------------------------------------------------------------- /docs/examples/scrollable.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | 3 | import React from 'react'; 4 | import Menu, { Item as MenuItem } from '@rc-component/menu'; 5 | 6 | import '../../assets/index.less'; 7 | 8 | const children = []; 9 | for (let i = 0; i < 20; i += 1) { 10 | children.push({i}); 11 | } 12 | 13 | const menuStyle = { 14 | width: 200, 15 | height: 200, 16 | overflow: 'auto', 17 | }; 18 | 19 | export default () => ( 20 |
21 |

keyboard scrollable menu

22 | {children} 23 |
24 | ); 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "21:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: "@types/react-dom" 11 | versions: 12 | - 17.0.0 13 | - 17.0.1 14 | - 17.0.2 15 | - dependency-name: "@types/react" 16 | versions: 17 | - 17.0.0 18 | - 17.0.1 19 | - 17.0.2 20 | - 17.0.3 21 | - dependency-name: np 22 | versions: 23 | - 7.2.0 24 | - 7.3.0 25 | - 7.4.0 26 | - dependency-name: less 27 | versions: 28 | - 4.1.0 29 | -------------------------------------------------------------------------------- /.dumirc.ts: -------------------------------------------------------------------------------- 1 | // more config: https://d.umijs.org/config 2 | import { defineConfig } from 'dumi'; 3 | 4 | export default defineConfig({ 5 | themeConfig: { 6 | name: 'rc-menu', 7 | logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', 8 | nav: [ 9 | { title: 'Demo', link: '/demo/antd'} 10 | ], 11 | }, 12 | favicons: 13 | ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'], 14 | outputPath: '.doc', 15 | exportStatic: {}, 16 | mfsu: {}, 17 | styles: [ 18 | ` 19 | .markdown table { 20 | width: auto !important; 21 | } 22 | `, 23 | ] 24 | }); 25 | -------------------------------------------------------------------------------- /src/utils/warnUtil.ts: -------------------------------------------------------------------------------- 1 | import warning from '@rc-component/util/lib/warning'; 2 | 3 | /** 4 | * `onClick` event return `info.item` which point to react node directly. 5 | * We should warning this since it will not work on FC. 6 | */ 7 | export function warnItemProp({ item, ...restInfo }: T): T { 8 | Object.defineProperty(restInfo, 'item', { 9 | get: () => { 10 | warning( 11 | false, 12 | '`info.item` is deprecated since we will move to function component that not provides React Node instance in future.', 13 | ); 14 | 15 | return item; 16 | }, 17 | }); 18 | 19 | return restInfo as T; 20 | } 21 | -------------------------------------------------------------------------------- /src/Divider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { clsx } from 'clsx'; 3 | import { MenuContext } from './context/MenuContext'; 4 | import { useMeasure } from './context/PathContext'; 5 | import type { MenuDividerType } from './interface'; 6 | 7 | export type DividerProps = Omit; 8 | 9 | export default function Divider({ className, style }: DividerProps) { 10 | const { prefixCls } = React.useContext(MenuContext); 11 | const measure = useMeasure(); 12 | 13 | if (measure) { 14 | return null; 15 | } 16 | 17 | return ( 18 |
  • 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/Icon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { RenderIconInfo, RenderIconType } from './interface'; 3 | 4 | export interface IconProps { 5 | icon?: RenderIconType; 6 | props: RenderIconInfo; 7 | /** Fallback of icon if provided */ 8 | children?: React.ReactElement; 9 | } 10 | 11 | export default function Icon({ icon, props, children }: IconProps) { 12 | let iconNode: React.ReactElement; 13 | 14 | if (icon === null || icon === false) { 15 | return null; 16 | } 17 | 18 | if (typeof icon === 'function') { 19 | iconNode = React.createElement(icon as any, { 20 | ...props, 21 | }); 22 | } else if (typeof icon !== "boolean") { 23 | // Compatible for origin definition 24 | iconNode = icon as React.ReactElement; 25 | } 26 | 27 | return iconNode || children || null; 28 | } 29 | -------------------------------------------------------------------------------- /docs/examples/menuItemGroup.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | 3 | import React from 'react'; 4 | import Menu, { Item as MenuItem, ItemGroup as MenuItemGroup } from '@rc-component/menu'; 5 | 6 | import '../../assets/index.less'; 7 | 8 | export default () => ( 9 |
    10 |

    menu item group

    11 | console.log('click')} 14 | classNames={{ listTitle: 'test-title', list: 'test-list' }} 15 | > 16 | 17 | 2 18 | 3 19 | 20 | 21 | 4 22 | 5 23 | 24 | 25 |
    26 | ); 27 | -------------------------------------------------------------------------------- /src/utils/commonUtil.ts: -------------------------------------------------------------------------------- 1 | import toArray from '@rc-component/util/lib/Children/toArray'; 2 | import * as React from 'react'; 3 | 4 | export function parseChildren(children: React.ReactNode | undefined, keyPath: string[]) { 5 | return toArray(children).map((child, index) => { 6 | if (React.isValidElement(child)) { 7 | const { key } = child; 8 | let eventKey = (child.props as any)?.eventKey ?? key; 9 | 10 | const emptyKey = eventKey === null || eventKey === undefined; 11 | 12 | if (emptyKey) { 13 | eventKey = `tmp_key-${[...keyPath, index].join('-')}`; 14 | } 15 | 16 | const cloneProps = { key: eventKey, eventKey } as any; 17 | 18 | if (process.env.NODE_ENV !== 'production' && emptyKey) { 19 | cloneProps.warnKey = true; 20 | } 21 | 22 | return React.cloneElement(child, cloneProps); 23 | } 24 | 25 | return child; 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [require.resolve('@umijs/fabric/dist/eslint')], 3 | rules: { 4 | 'import/no-extraneous-dependencies': 0, 5 | 'import/no-named-as-default': 0, 6 | 'no-template-curly-in-string': 0, 7 | 'prefer-promise-reject-errors': 0, 8 | 'react/no-array-index-key': 0, 9 | 'react/require-default-props': 0, 10 | 'react/sort-comp': 0, 11 | 'react/no-find-dom-node': 1, 12 | '@typescript-eslint/no-explicit-any': 0, 13 | 'jsx-a11y/label-has-associated-control': 0, 14 | 'jsx-a11y/label-has-for': 0, 15 | '@typescript-eslint/no-empty-interface': 0, 16 | '@typescript-eslint/consistent-indexed-object-style': 0, 17 | '@typescript-eslint/switch-exhaustiveness-check': 0, 18 | '@typescript-eslint/no-parameter-properties': 0, 19 | '@typescript-eslint/no-throw-literal': 0, 20 | '@typescript-eslint/type-annotation-spacing': 0, 21 | '@typescript-eslint/ban-types': 0, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ant-design # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: ant-design # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "12 22 * * 6" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /docs/examples/fragment.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Menu, { SubMenu, Item as MenuItem, Divider } from '@rc-component/menu'; 3 | import '../../assets/index.less'; 4 | 5 | export default () => ( 6 | 7 | 8 | 0-1 9 | 0-2 10 | 11 | Menu Item 12 | outer 13 | <> 14 | 15 | inner inner 16 | 17 | 18 | inn 19 | 20 | inner inner 21 | inner inner2 22 | 23 | 24 | 25 | disabled 26 | outer3 27 | 28 | 29 | ); 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-present yiminghe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/SubMenu/SubMenuList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { clsx } from 'clsx'; 3 | import { MenuContext } from '../context/MenuContext'; 4 | 5 | export interface SubMenuListProps extends React.HTMLAttributes { 6 | children?: React.ReactNode; 7 | } 8 | 9 | const InternalSubMenuList = ( 10 | { className, children, ...restProps }: SubMenuListProps, 11 | ref: React.Ref, 12 | ) => { 13 | const { prefixCls, mode, rtl } = React.useContext(MenuContext); 14 | 15 | return ( 16 |
      29 | {children} 30 |
    31 | ); 32 | }; 33 | 34 | const SubMenuList = React.forwardRef(InternalSubMenuList); 35 | 36 | if (process.env.NODE_ENV !== 'production') { 37 | SubMenuList.displayName = 'SubMenuList'; 38 | } 39 | 40 | export default SubMenuList; 41 | -------------------------------------------------------------------------------- /tests/Options.spec.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { render } from '@testing-library/react'; 3 | import React from 'react'; 4 | import Menu from '../src'; 5 | 6 | describe('Options', () => { 7 | it('should work', () => { 8 | const { container } = render( 9 | , 40 | ); 41 | 42 | expect(container.children).toMatchSnapshot(); 43 | }); 44 | }); 45 | /* eslint-enable */ 46 | -------------------------------------------------------------------------------- /docs/examples/antd-switch.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console, react/require-default-props, no-param-reassign */ 2 | 3 | import React from 'react'; 4 | import { CommonMenu, inlineMotion } from './antd'; 5 | import '../../assets/index.less'; 6 | 7 | const Demo = () => { 8 | const [inline, setInline] = React.useState(false); 9 | const [openKeys, setOpenKey] = React.useState(['1']); 10 | 11 | let restProps = {}; 12 | if (inline) { 13 | restProps = { motion: inlineMotion }; 14 | } else { 15 | restProps = { openAnimation: 'zoom' }; 16 | } 17 | 18 | return ( 19 |
    20 | 28 | { 32 | console.error('Open Keys Changed:', keys); 33 | setOpenKey(keys); 34 | }} 35 | inlineCollapsed={!inline} 36 | {...restProps} 37 | /> 38 |
    39 | ); 40 | }; 41 | 42 | export default Demo; 43 | /* eslint-enable */ 44 | -------------------------------------------------------------------------------- /src/hooks/useActive.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { MenuContext } from '../context/MenuContext'; 3 | import type { MenuHoverEventHandler } from '../interface'; 4 | 5 | interface ActiveObj { 6 | active: boolean; 7 | onMouseEnter?: React.MouseEventHandler; 8 | onMouseLeave?: React.MouseEventHandler; 9 | } 10 | 11 | export default function useActive( 12 | eventKey: string, 13 | disabled: boolean, 14 | onMouseEnter?: MenuHoverEventHandler, 15 | onMouseLeave?: MenuHoverEventHandler, 16 | ): ActiveObj { 17 | const { 18 | // Active 19 | activeKey, 20 | onActive, 21 | onInactive, 22 | } = React.useContext(MenuContext); 23 | 24 | const ret: ActiveObj = { 25 | active: activeKey === eventKey, 26 | }; 27 | 28 | // Skip when disabled 29 | if (!disabled) { 30 | ret.onMouseEnter = domEvent => { 31 | onMouseEnter?.({ 32 | key: eventKey, 33 | domEvent, 34 | }); 35 | onActive(eventKey); 36 | }; 37 | ret.onMouseLeave = domEvent => { 38 | onMouseLeave?.({ 39 | key: eventKey, 40 | domEvent, 41 | }); 42 | onInactive(eventKey); 43 | }; 44 | } 45 | 46 | return ret; 47 | } 48 | -------------------------------------------------------------------------------- /docs/examples/keyPath.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | 3 | import React from 'react'; 4 | import Menu, { SubMenu, Item as MenuItem } from '@rc-component/menu'; 5 | 6 | import '../../assets/index.less'; 7 | 8 | class Test extends React.Component { 9 | onClick = info => { 10 | console.log('click ', info); 11 | }; 12 | 13 | getMenu() { 14 | return ( 15 | 16 | 17 | item1-1 18 | item1-2 19 | 20 | 21 | item2-1 22 | item2-2 23 | 24 | item2-3-1 25 | item2-3-2 26 | 27 | 28 | item3 29 | 30 | ); 31 | } 32 | 33 | render() { 34 | return ( 35 |
    36 |
    {this.getMenu()}
    37 |
    38 | ); 39 | } 40 | } 41 | 42 | export default Test; 43 | -------------------------------------------------------------------------------- /docs/examples/single.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | 3 | import React from 'react'; 4 | import Menu from '@rc-component/menu'; 5 | import '../../assets/index.less'; 6 | 7 | const menu1List = [ 8 | { 9 | titleLocalKey: 'Properties', 10 | key: 'Properties', 11 | }, 12 | { 13 | titleLocalKey: 'Resources', 14 | key: 'Resources', 15 | children: [ 16 | { 17 | titleLocalKey: 'FAQ', 18 | key: 'Faq', 19 | isSub: true, 20 | }, 21 | { 22 | titleLocalKey: 'Learn', 23 | key: 'Learn', 24 | isSub: true, 25 | }, 26 | ], 27 | }, 28 | { 29 | titleLocalKey: 'About Us', 30 | key: 'AboutUs', 31 | }, 32 | ]; 33 | 34 | const menu1Items = values => { 35 | if (!values) { 36 | return undefined; 37 | } 38 | return values.map((item, index) => { 39 | return { 40 | label:
    {item.titleLocalKey}
    , 41 | key: item.key, 42 | children: menu1Items(item.children), 43 | }; 44 | }); 45 | }; 46 | 47 | console.log(menu1Items(menu1List)); 48 | 49 | export default () => ( 50 | 57 | ); 58 | -------------------------------------------------------------------------------- /tests/Private.spec.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { render } from '@testing-library/react'; 3 | import { clsx } from 'clsx'; 4 | import React from 'react'; 5 | import Menu, { MenuItem, SubMenu } from '../src'; 6 | 7 | describe('Private Props', () => { 8 | it('_internalRenderMenuItem', () => { 9 | const { container } = render( 10 | 12 | React.cloneElement(node, { className: clsx(node.props.className, 'inject-cls') }) 13 | } 14 | > 15 | 1 16 | , 17 | ); 18 | 19 | expect(container.querySelector('.inject-cls')).toBeTruthy(); 20 | }); 21 | 22 | it('_internalRenderSubMenuItem', () => { 23 | const { container } = render( 24 | 28 | React.cloneElement(node, { className: clsx(node.props.className, 'inject-cls') }) 29 | } 30 | > 31 | 32 | 1-1 33 | 34 | , 35 | ); 36 | 37 | expect(container.querySelector('.inject-cls')).toBeTruthy(); 38 | }); 39 | }); 40 | /* eslint-enable */ 41 | -------------------------------------------------------------------------------- /src/context/PathContext.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | const EmptyList: string[] = []; 4 | 5 | // ========================= Path Register ========================= 6 | export interface PathRegisterContextProps { 7 | registerPath: (key: string, keyPath: string[]) => void; 8 | unregisterPath: (key: string, keyPath: string[]) => void; 9 | } 10 | 11 | export const PathRegisterContext = React.createContext( 12 | null, 13 | ); 14 | 15 | export function useMeasure() { 16 | return React.useContext(PathRegisterContext); 17 | } 18 | 19 | // ========================= Path Tracker ========================== 20 | export const PathTrackerContext = React.createContext(EmptyList); 21 | 22 | export function useFullPath(eventKey?: string) { 23 | const parentKeyPath = React.useContext(PathTrackerContext); 24 | return React.useMemo( 25 | () => 26 | eventKey !== undefined ? [...parentKeyPath, eventKey] : parentKeyPath, 27 | [parentKeyPath, eventKey], 28 | ); 29 | } 30 | 31 | // =========================== Path User =========================== 32 | export interface PathUserContextProps { 33 | isSubPathKey: (pathKeys: string[], eventKey: string) => boolean; 34 | } 35 | 36 | export const PathUserContext = React.createContext(null); 37 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Menu from './Menu'; 2 | import MenuItem from './MenuItem'; 3 | import SubMenu from './SubMenu'; 4 | import MenuItemGroup from './MenuItemGroup'; 5 | import { useFullPath } from './context/PathContext'; 6 | import Divider from './Divider'; 7 | import type { MenuProps } from './Menu'; 8 | import type { MenuItemProps } from './MenuItem'; 9 | import type { SubMenuProps } from './SubMenu'; 10 | import type { MenuItemGroupProps } from './MenuItemGroup'; 11 | import type { MenuRef } from './interface'; 12 | 13 | export { 14 | SubMenu, 15 | MenuItem as Item, 16 | MenuItem, 17 | MenuItemGroup, 18 | MenuItemGroup as ItemGroup, 19 | Divider, 20 | /** @private Only used for antd internal. Do not use in your production. */ 21 | useFullPath, 22 | }; 23 | 24 | export type { 25 | MenuProps, 26 | SubMenuProps, 27 | MenuItemProps, 28 | MenuItemGroupProps, 29 | MenuRef, 30 | }; 31 | 32 | type MenuType = typeof Menu & { 33 | Item: typeof MenuItem; 34 | SubMenu: typeof SubMenu; 35 | ItemGroup: typeof MenuItemGroup; 36 | Divider: typeof Divider; 37 | }; 38 | 39 | const ExportMenu = Menu as MenuType; 40 | 41 | ExportMenu.Item = MenuItem; 42 | ExportMenu.SubMenu = SubMenu; 43 | ExportMenu.ItemGroup = MenuItemGroup; 44 | ExportMenu.Divider = Divider; 45 | 46 | export default ExportMenu; 47 | -------------------------------------------------------------------------------- /docs/examples/inlineCollapsed.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Menu from '@rc-component/menu'; 3 | import './inlineCollapsed.less'; 4 | 5 | const App = () => { 6 | const [collapsed, setCollapsed] = useState(false); 7 | return ( 8 | <> 9 | 13 | 39 | 40 | ); 41 | }; 42 | 43 | export default App; 44 | -------------------------------------------------------------------------------- /docs/examples/openKeys.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | 3 | import React from 'react'; 4 | import Menu, { SubMenu, Item as MenuItem } from '@rc-component/menu'; 5 | 6 | import '../../assets/index.less'; 7 | 8 | class Test extends React.Component { 9 | state = { 10 | openKeys: [], 11 | }; 12 | 13 | onClick = info => { 14 | console.log('click ', info); 15 | }; 16 | 17 | onOpenChange = openKeys => { 18 | console.log('onOpenChange', openKeys); 19 | this.setState({ 20 | openKeys, 21 | }); 22 | }; 23 | 24 | getMenu() { 25 | return ( 26 | 32 | 33 | item1-1 34 | item1-2 35 | 36 | 37 | item2-1 38 | item2-2 39 | 40 | item3 41 | 42 | ); 43 | } 44 | 45 | render() { 46 | return ( 47 |
    48 |
    {this.getMenu()}
    49 |
    50 | ); 51 | } 52 | } 53 | 54 | export default Test; 55 | -------------------------------------------------------------------------------- /tests/__snapshots__/Keyboard.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Menu.Keyboard no data-menu-id by init 1`] = ` 4 | HTMLCollection [ 5 | , 49 |