π§ Accessible React menu primitives for navigation, command surfaces, and nested item trees.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index cfb19eb7..5b100362 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,6 +1,6 @@
@rc-component/menu
-
Ant Design ηζηδΈι¨εγ
+
Ant Design ηζηδΈι¨εγ
π§ React θεη»δ»ΆοΌζ―ζζ°΄εΉ³γεη΄γε
θγεη»εεθεγ
diff --git a/docs/examples/customPopupRender.tsx b/docs/examples/customPopupRender.tsx
index cf2d7138..4444255d 100644
--- a/docs/examples/customPopupRender.tsx
+++ b/docs/examples/customPopupRender.tsx
@@ -67,10 +67,10 @@ const NavigationDemo = () => {
],
},
];
- const popupRender = (node: ReactElement) => (
+ const popupRender = (node: ReactElement) => (
- {React.Children.map(node.props.children.props.children, child => (
+ {React.Children.map(node.props.children.props.children, (child: ReactElement
) => (
{React.cloneElement(child, {
className: `${child.props.className || ''} navigation-menu-item`,
@@ -85,13 +85,13 @@ const NavigationDemo = () => {
};
const MixedPanelDemo = () => {
- const totalPopupRender = (node: ReactElement, info: { item: any; keys: string[] }) => {
+ const totalPopupRender = (node: ReactElement
, info: { item: any; keys: string[] }) => {
const isSecondLevel = info.keys.length == 2;
if (isSecondLevel) {
return (
- {React.Children.map(node.props.children.props.children, child => (
+ {React.Children.map(node.props.children.props.children, (child: ReactElement
) => (
{React.cloneElement(child, {
className: `${child.props.className || ''} navigation-menu-item`,
diff --git a/docs/examples/debug.tsx b/docs/examples/debug.tsx
index 0fca53ab..f1f9d2ab 100644
--- a/docs/examples/debug.tsx
+++ b/docs/examples/debug.tsx
@@ -52,7 +52,7 @@ export default () => {
const [inlineCollapsed, setInlineCollapsed] = React.useState(false);
const [forceRender, setForceRender] = React.useState(false);
const [openKeys, setOpenKeys] = React.useState
([]);
- const menuRef = useRef();
+ const menuRef = useRef(null);
const onRootClick = (info: MenuInfo) => {
console.log('Root Menu Item Click:', info);
diff --git a/docs/examples/items-ref.tsx b/docs/examples/items-ref.tsx
index dc18f9f1..b8736e92 100644
--- a/docs/examples/items-ref.tsx
+++ b/docs/examples/items-ref.tsx
@@ -5,13 +5,13 @@ import '../../assets/index.less';
import Menu from '../../src';
export default () => {
- const ref1 = useRef();
- const ref2 = useRef();
- const ref3 = useRef();
- const ref4 = useRef();
- const ref5 = useRef();
- const ref6 = useRef();
- const ref7 = useRef();
+ const ref1 = useRef(null);
+ const ref2 = useRef(null);
+ const ref3 = useRef(null);
+ const ref4 = useRef(null);
+ const ref5 = useRef(null);
+ const ref6 = useRef(null);
+ const ref7 = useRef(null);
return (
<>
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 00000000..40f08ec9
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,115 @@
+import js from '@eslint/js';
+import { defineConfig } from 'eslint/config';
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import prettier from 'eslint-config-prettier';
+import jest from 'eslint-plugin-jest';
+import react from 'eslint-plugin-react';
+import reactHooks from 'eslint-plugin-react-hooks';
+import globals from 'globals';
+import tseslint from 'typescript-eslint';
+
+const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
+
+export default defineConfig([
+ {
+ plugins: {
+ '@typescript-eslint': tseslint.plugin,
+ },
+ },
+ {
+ linterOptions: {
+ reportUnusedDisableDirectives: 'warn',
+ },
+ },
+ {
+ ignores: [
+ 'node_modules/',
+ 'coverage/',
+ 'es/',
+ 'lib/',
+ 'dist/',
+ 'docs-dist/',
+ '.docs-dist/',
+ '.dumi/',
+ '.doc/',
+ '.vercel/',
+ ],
+ },
+ {
+ files: ['**/*.{js,jsx,ts,tsx}'],
+ extends: [
+ js.configs.recommended,
+ react.configs.flat.recommended,
+ react.configs.flat['jsx-runtime'],
+ prettier,
+ ],
+ plugins: {
+ 'react-hooks': reactHooks,
+ },
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ },
+ },
+ settings: {
+ react: {
+ version: 'detect',
+ },
+ },
+ rules: {
+ 'no-async-promise-executor': 'off',
+ 'no-empty-pattern': 'off',
+ 'no-irregular-whitespace': 'off',
+ 'no-prototype-builtins': 'off',
+ 'no-useless-escape': 'off',
+ 'no-extra-boolean-cast': 'off',
+ 'no-undef': 'off',
+ 'no-unused-vars': 'off',
+ 'react/no-find-dom-node': 'off',
+ 'react/display-name': 'off',
+ 'react/no-unknown-property': 'off',
+ 'react/prop-types': 'off',
+ 'react-hooks/exhaustive-deps': 'warn',
+ 'react-hooks/rules-of-hooks': 'error',
+ },
+ },
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [...tseslint.configs.recommended],
+ rules: {
+ '@typescript-eslint/ban-ts-comment': 'off',
+ '@typescript-eslint/no-empty-object-type': 'off',
+ '@typescript-eslint/no-explicit-any': 'off',
+ '@typescript-eslint/no-unsafe-function-type': 'off',
+ '@typescript-eslint/no-unnecessary-type-constraint': 'off',
+ '@typescript-eslint/no-unused-vars': 'off',
+ },
+ },
+ {
+ files: ['src/**/*.{ts,tsx}'],
+ languageOptions: {
+ parserOptions: {
+ projectService: true,
+ tsconfigRootDir,
+ },
+ },
+ },
+ {
+ files: ['tests/**/*.{js,jsx,ts,tsx}', '**/*.{test,spec}.{js,jsx,ts,tsx}'],
+ extends: [jest.configs['flat/recommended']],
+ rules: {
+ 'jest/no-disabled-tests': 'off',
+ 'jest/no-done-callback': 'off',
+ 'jest/no-identical-title': 'off',
+ 'jest/expect-expect': 'off',
+ 'jest/no-alias-methods': 'off',
+ 'jest/no-conditional-expect': 'off',
+ 'jest/no-export': 'off',
+ 'jest/no-standalone-expect': 'off',
+ 'jest/valid-expect': 'off',
+ 'jest/valid-title': 'off',
+ },
+ },
+]);
diff --git a/global.d.ts b/global.d.ts
new file mode 100644
index 00000000..e0bd355c
--- /dev/null
+++ b/global.d.ts
@@ -0,0 +1,11 @@
+///
+///
+///
+///
+///
+
+declare module '*.css';
+declare module '*.less';
+declare module 'jsonp';
+
+declare module 'moment/locale/zh-cn';
diff --git a/package.json b/package.json
index 3fe3f9d4..76272dfe 100644
--- a/package.json
+++ b/package.json
@@ -53,29 +53,36 @@
"clsx": "^2.1.1"
},
"devDependencies": {
+ "@eslint/js": "^9.39.4",
"@rc-component/father-plugin": "^2.2.0",
"@rc-component/np": "^1.0.4",
+ "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
- "@testing-library/react": "^15.0.7",
- "@types/jest": "^29.5.14",
+ "@testing-library/react": "^16.3.2",
+ "@types/jest": "^30.0.0",
"@types/node": "^26.0.1",
- "@types/react": "^18.3.31",
- "@types/react-dom": "^18.3.7",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
"@types/warning": "^3.0.4",
- "@umijs/fabric": "^4.0.1",
"cross-env": "^10.1.0",
- "dumi": "^2.4.35",
- "eslint": "^8.57.1",
- "father": "^4.6.23",
+ "dumi": "^2.4.38",
+ "eslint": "^9.39.4",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-jest": "^29.15.4",
+ "eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "father": "^4.6.24",
"gh-pages": "^6.3.0",
+ "globals": "^17.7.0",
"husky": "^9.1.7",
"less": "^4.6.7",
- "lint-staged": "^16.4.0",
- "prettier": "^3.9.0",
+ "lint-staged": "^17.0.8",
+ "prettier": "^3.9.4",
"rc-test": "^7.1.3",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
- "typescript": "^5.9.3"
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7",
+ "typescript": "^6.0.3",
+ "typescript-eslint": "^8.62.1"
},
"peerDependencies": {
"react": ">=16.9.0",
diff --git a/src/Menu.tsx b/src/Menu.tsx
index 8c701ba4..65a67ca3 100644
--- a/src/Menu.tsx
+++ b/src/Menu.tsx
@@ -257,7 +257,7 @@ const Menu = React.forwardRef((props, ref) => {
const [mounted, setMounted] = React.useState(false);
- const containerRef = React.useRef();
+ const containerRef = React.useRef(null);
const uuid = useId(id ? `rc-menu-uuid-${id}` : 'rc-menu-uuid');
@@ -400,7 +400,7 @@ const Menu = React.forwardRef((props, ref) => {
} else {
shouldFocusKey = focusableElements[0]
? element2key.get(focusableElements[0])
- : childList.find(node => !node.props.disabled)?.key;
+ : childList.find((node: React.ReactElement) => !node.props.disabled)?.key;
}
const elementToFocus = key2element.get(shouldFocusKey);
diff --git a/src/MenuItem.tsx b/src/MenuItem.tsx
index 74882518..cda3d0c4 100644
--- a/src/MenuItem.tsx
+++ b/src/MenuItem.tsx
@@ -117,8 +117,8 @@ const InternalMenuItem = React.forwardRef((props: MenuItemProps, ref: React.Ref<
const itemCls = `${prefixCls}-item`;
- const legacyMenuItemRef = React.useRef();
- const elementRef = React.useRef();
+ const legacyMenuItemRef = React.useRef(null);
+ const elementRef = React.useRef(null);
const mergedDisabled = contextDisabled || disabled;
const mergedEleRef = useComposeRef(ref, elementRef);
diff --git a/src/SubMenu/PopupTrigger.tsx b/src/SubMenu/PopupTrigger.tsx
index 0cbea7f8..3392ede7 100644
--- a/src/SubMenu/PopupTrigger.tsx
+++ b/src/SubMenu/PopupTrigger.tsx
@@ -82,7 +82,7 @@ export default function PopupTrigger({
};
// Delay to change visible
- const visibleRef = React.useRef();
+ const visibleRef = React.useRef(undefined);
React.useEffect(() => {
visibleRef.current = raf(() => {
setInnerVisible(visible);
diff --git a/src/SubMenu/index.tsx b/src/SubMenu/index.tsx
index 542da8c5..e95e96dd 100644
--- a/src/SubMenu/index.tsx
+++ b/src/SubMenu/index.tsx
@@ -117,8 +117,8 @@ const InternalSubMenu = React.forwardRef((props, re
const subMenuPrefixCls = `${prefixCls}-submenu`;
const mergedDisabled = contextDisabled || disabled;
- const elementRef = React.useRef();
- const popupRef = React.useRef();
+ const elementRef = React.useRef(null);
+ const popupRef = React.useRef(null);
// ================================ Warn ================================
if (process.env.NODE_ENV !== 'production' && warnKey) {
diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts
index 12ca3372..259130cb 100644
--- a/src/hooks/useAccessibility.ts
+++ b/src/hooks/useAccessibility.ts
@@ -177,9 +177,9 @@ export function useAccessibility(
originOnKeyDown?: React.KeyboardEventHandler,
): React.KeyboardEventHandler {
- const rafRef = React.useRef();
+ const rafRef = React.useRef(undefined);
- const activeRef = React.useRef();
+ const activeRef = React.useRef(undefined);
activeRef.current = activeKey;
const cleanRaf = () => {
diff --git a/tests/Collapsed.spec.tsx b/tests/Collapsed.spec.tsx
index ee6f1c7d..a74729c3 100644
--- a/tests/Collapsed.spec.tsx
+++ b/tests/Collapsed.spec.tsx
@@ -27,24 +27,18 @@ describe('Menu.Collapsed', () => {
const { container, rerender } = render(genMenu());
// Inline
- expect(container.querySelector('ul.rc-menu-sub')).not.toHaveClass(
- 'rc-menu-hidden',
- );
+ expect(container.querySelector('ul.rc-menu-sub')).not.toHaveClass('rc-menu-hidden');
// Vertical
rerender(genMenu({ mode: 'vertical' }));
act(() => {
jest.runAllTimers();
});
- expect(container.querySelector('ul.rc-menu-sub')).not.toHaveClass(
- 'rc-menu-hidden',
- );
+ expect(container.querySelector('ul.rc-menu-sub')).not.toHaveClass('rc-menu-hidden');
// Inline
rerender(genMenu({ mode: 'inline' }));
- expect(container.querySelector('ul.rc-menu-sub')).not.toHaveClass(
- 'rc-menu-hidden',
- );
+ expect(container.querySelector('ul.rc-menu-sub')).not.toHaveClass('rc-menu-hidden');
});
it('should always follow submenu popup hidden when mode is switched', () => {
@@ -61,9 +55,7 @@ describe('Menu.Collapsed', () => {
const { container, rerender } = render(genMenu());
// Hover submenu1
- fireEvent.mouseEnter(
- container.querySelectorAll('.rc-menu-submenu-title')[0],
- );
+ fireEvent.mouseEnter(container.querySelectorAll('.rc-menu-submenu-title')[0]);
act(() => {
jest.runAllTimers();
@@ -73,9 +65,7 @@ describe('Menu.Collapsed', () => {
});
// Hover submenu1-1
- fireEvent.mouseEnter(
- container.querySelectorAll('.rc-menu-submenu-title')[1],
- );
+ fireEvent.mouseEnter(container.querySelectorAll('.rc-menu-submenu-title')[1]);
act(() => {
jest.runAllTimers();
@@ -114,12 +104,8 @@ describe('Menu.Collapsed', () => {
);
const { container, rerender } = render(genMenu());
- expect(container.querySelector('ul.rc-menu-sub')).toHaveClass(
- 'rc-menu-inline',
- );
- expect(container.querySelector('ul.rc-menu-sub')).not.toHaveClass(
- 'rc-menu-hidden',
- );
+ expect(container.querySelector('ul.rc-menu-sub')).toHaveClass('rc-menu-inline');
+ expect(container.querySelector('ul.rc-menu-sub')).not.toHaveClass('rc-menu-hidden');
rerender(genMenu({ inlineCollapsed: true }));
// ε¨η»η»ζεε₯ζ ·εΌ;
@@ -135,9 +121,7 @@ describe('Menu.Collapsed', () => {
jest.runAllTimers();
});
- expect(container.querySelector('ul.rc-menu-root')).toHaveClass(
- 'rc-menu-vertical',
- );
+ expect(container.querySelector('ul.rc-menu-root')).toHaveClass('rc-menu-vertical');
expect(container.querySelectorAll('ul.rc-menu-sub')).toHaveLength(0);
rerender(genMenu({ inlineCollapsed: false }));
@@ -145,12 +129,8 @@ describe('Menu.Collapsed', () => {
jest.runAllTimers();
});
- expect(container.querySelector('ul.rc-menu-sub')).toHaveClass(
- 'rc-menu-inline',
- );
- expect(container.querySelector('ul.rc-menu-sub')).not.toHaveClass(
- 'rc-menu-hidden',
- );
+ expect(container.querySelector('ul.rc-menu-sub')).toHaveClass('rc-menu-inline');
+ expect(container.querySelector('ul.rc-menu-sub')).not.toHaveClass('rc-menu-hidden');
});
it('inlineCollapsed should works well when specify a not existed default openKeys', () => {
@@ -199,20 +179,12 @@ describe('Menu.Collapsed', () => {
jest.runAllTimers();
});
- expect(container.querySelector('.rc-menu-submenu')).toHaveClass(
- 'rc-menu-submenu-vertical',
- );
+ expect(container.querySelector('.rc-menu-submenu')).toHaveClass('rc-menu-submenu-vertical');
- expect(container.querySelector('.rc-menu-submenu')).toHaveClass(
- 'rc-menu-submenu-open',
- );
+ expect(container.querySelector('.rc-menu-submenu')).toHaveClass('rc-menu-submenu-open');
- expect(container.querySelector('ul.rc-menu-sub')).toHaveClass(
- 'rc-menu-vertical',
- );
- expect(container.querySelector('ul.rc-menu-sub')).not.toHaveClass(
- 'rc-menu-hidden',
- );
+ expect(container.querySelector('ul.rc-menu-sub')).toHaveClass('rc-menu-vertical');
+ expect(container.querySelector('ul.rc-menu-sub')).not.toHaveClass('rc-menu-hidden');
});
it('inlineCollapsed MenuItem Tooltip can be removed', () => {
@@ -271,15 +243,11 @@ describe('Menu.Collapsed', () => {
const { container, rerender } = render(genMenu());
// Default
- expect(
- container.querySelector('.rc-menu-item-selected').textContent,
- ).toBe('Option 1');
+ expect(container.querySelector('.rc-menu-item-selected').textContent).toBe('Option 1');
// Click to change select
fireEvent.click(container.querySelectorAll('.rc-menu-item')[1]);
- expect(
- container.querySelector('.rc-menu-item-selected').textContent,
- ).toBe('Option 2');
+ expect(container.querySelector('.rc-menu-item-selected').textContent).toBe('Option 2');
// Collapse it
rerender(genMenu({ inlineCollapsed: true }));
@@ -292,20 +260,12 @@ describe('Menu.Collapsed', () => {
// Expand it
rerender(genMenu({ inlineCollapsed: false }));
- expect(
- container.querySelector('.rc-menu-item-selected').textContent,
- ).toBe('Option 2');
+ expect(container.querySelector('.rc-menu-item-selected').textContent).toBe('Option 2');
});
it('should hideMenu in initial state when collapsed', () => {
const genMenu = (props?) => (
-