From d37007c69c3e1ff4a66d414cbb1a4a38bb22264c Mon Sep 17 00:00:00 2001
From: winches <329487092@qq.com>
Date: Thu, 28 Nov 2024 21:17:11 +0800
Subject: [PATCH] fix: menu item classNames not work (#4156)

* fix: menu item classNames not work

* feat: changeset

* docs: update

* feat: merge classes utility added

* Update .changeset/brave-trains-wave.md

---------

Co-authored-by: WK Wong <wingkwong.code@gmail.com>
Co-authored-by: Junior Garcia <jrgarciadev@gmail.com>
---
 .changeset/brave-trains-wave.md               |  6 +++
 .../docs/content/docs/components/dropdown.mdx |  2 +-
 .../components/menu/__tests__/menu.test.tsx   | 41 +++++++++++++++++++
 packages/components/menu/src/menu.tsx         |  7 +++-
 packages/core/theme/src/utils/index.ts        |  1 +
 .../core/theme/src/utils/merge-classes.ts     | 26 ++++++++++++
 6 files changed, 80 insertions(+), 3 deletions(-)
 create mode 100644 .changeset/brave-trains-wave.md
 create mode 100644 packages/core/theme/src/utils/merge-classes.ts

diff --git a/.changeset/brave-trains-wave.md b/.changeset/brave-trains-wave.md
new file mode 100644
index 0000000000..c81facf5b1
--- /dev/null
+++ b/.changeset/brave-trains-wave.md
@@ -0,0 +1,6 @@
+---
+"@nextui-org/menu": patch
+"@nextui-org/theme": patch
+---
+
+Fix menu item classNames not work (#4119)
diff --git a/apps/docs/content/docs/components/dropdown.mdx b/apps/docs/content/docs/components/dropdown.mdx
index 24e48716d0..3ebad0239c 100644
--- a/apps/docs/content/docs/components/dropdown.mdx
+++ b/apps/docs/content/docs/components/dropdown.mdx
@@ -386,7 +386,7 @@ you to customize each item individually.
 | isReadOnly       | `boolean`                                                                                     | Whether the dropdown item press events should be ignored.                                                                                                                        | `false` |
 | hideSelectedIcon | `boolean`                                                                                     | Whether to hide the check icon when the item is selected.                                                                                                                        | `false` |
 | closeOnSelect    | `boolean`                                                                                     | Whether the dropdown menu should be closed when the item is selected.                                                                                                            | `true`  |
-| classNames       | `Record<"base"| "wrapper"| "title"| "description"| "shortcut" | "selectedIcon", string>` | Allows to set custom class names for the dropdown item slots.                                                                                                                    | -       |
+| classNames       | `Record<"base"| "wrapper"| "title"| "description"| "shortcut" | "selectedIcon", string>` | Allows to set custom class names for the dropdown item slots, which will override the menu `itemClasses`.                                                                                                                    | -       |
 
 ### DropdownItem Events
 
diff --git a/packages/components/menu/__tests__/menu.test.tsx b/packages/components/menu/__tests__/menu.test.tsx
index d8a9a53ddd..eeaa3eb068 100644
--- a/packages/components/menu/__tests__/menu.test.tsx
+++ b/packages/components/menu/__tests__/menu.test.tsx
@@ -344,6 +344,47 @@ describe("Menu", () => {
     expect(onClick).toHaveBeenCalledTimes(1);
   });
 
+  it("should menuItem classNames work", () => {
+    const wrapper = render(
+      <Menu>
+        <MenuItem classNames={{title: "test"}}>New file</MenuItem>
+      </Menu>,
+    );
+    const menuItem = wrapper.getByText("New file");
+
+    expect(menuItem.classList.contains("test")).toBeTruthy();
+  });
+
+  it("should menuItem classNames override menu itemClasses", () => {
+    const wrapper = render(
+      <Menu itemClasses={{title: "test"}}>
+        <MenuItem classNames={{title: "test2"}}>New file</MenuItem>
+      </Menu>,
+    );
+    const menuItem = wrapper.getByText("New file");
+
+    expect(menuItem.classList.contains("test2")).toBeTruthy();
+  });
+  it("should merge menu item classNames with itemClasses", () => {
+    const wrapper = render(
+      <Menu itemClasses={{title: "test"}}>
+        <MenuItem classNames={{title: "test2"}}>New file</MenuItem>
+        <MenuItem>Delete file</MenuItem>
+      </Menu>,
+    );
+
+    const menuItemWithBoth = wrapper.getByText("New file");
+    const menuItemWithDefault = wrapper.getByText("Delete file");
+
+    // Check first MenuItem has both classes
+    expect(menuItemWithBoth.classList.contains("test2")).toBeTruthy();
+    expect(menuItemWithBoth.classList.contains("test")).toBeTruthy();
+
+    // Check second MenuItem only has the default class
+    expect(menuItemWithDefault.classList.contains("test")).toBeTruthy();
+    expect(menuItemWithDefault.classList.contains("test2")).toBeFalsy();
+  });
+
   it("should truncate the text if the child is not a string", () => {
     const wrapper = render(
       <Menu>
diff --git a/packages/components/menu/src/menu.tsx b/packages/components/menu/src/menu.tsx
index a4658b4f28..bff90fb8d1 100644
--- a/packages/components/menu/src/menu.tsx
+++ b/packages/components/menu/src/menu.tsx
@@ -1,5 +1,6 @@
 import {forwardRef} from "@nextui-org/system";
 import {ForwardedRef, ReactElement, Ref} from "react";
+import {mergeClasses} from "@nextui-org/theme";
 
 import {UseMenuProps, useMenu} from "./use-menu";
 import MenuSection from "./menu-section";
@@ -48,10 +49,12 @@ function Menu<T extends object>(props: Props<T>, ref: ForwardedRef<HTMLUListElem
           ...item.props,
         };
 
+        const mergedItemClasses = mergeClasses(itemClasses, itemProps?.classNames);
+
         if (item.type === "section") {
-          return <MenuSection key={item.key} {...itemProps} itemClasses={itemClasses} />;
+          return <MenuSection key={item.key} {...itemProps} itemClasses={mergedItemClasses} />;
         }
-        let menuItem = <MenuItem key={item.key} {...itemProps} classNames={itemClasses} />;
+        let menuItem = <MenuItem key={item.key} {...itemProps} classNames={mergedItemClasses} />;
 
         if (item.wrapper) {
           menuItem = item.wrapper(menuItem);
diff --git a/packages/core/theme/src/utils/index.ts b/packages/core/theme/src/utils/index.ts
index 262655a859..2a096ff86a 100644
--- a/packages/core/theme/src/utils/index.ts
+++ b/packages/core/theme/src/utils/index.ts
@@ -11,4 +11,5 @@ export {
 export type {SlotsToClasses} from "./types";
 export {colorVariants} from "./variants";
 export {COMMON_UNITS, twMergeConfig} from "./tw-merge-config";
+export {mergeClasses} from "./merge-classes";
 export {cn} from "./cn";
diff --git a/packages/core/theme/src/utils/merge-classes.ts b/packages/core/theme/src/utils/merge-classes.ts
new file mode 100644
index 0000000000..02ab8a914f
--- /dev/null
+++ b/packages/core/theme/src/utils/merge-classes.ts
@@ -0,0 +1,26 @@
+import type {SlotsToClasses} from "./types";
+
+import {clsx} from "@nextui-org/shared-utils";
+
+/**
+ * Merges two sets of class names for each slot in a component.
+ * @param itemClasses - Base classes for each slot
+ * @param itemPropsClasses - Additional classes from props for each slot
+ * @returns A merged object containing the combined classes for each slot
+ */
+export const mergeClasses = <T extends SlotsToClasses<string>, P extends SlotsToClasses<string>>(
+  itemClasses?: T,
+  itemPropsClasses?: P,
+): T => {
+  if (!itemClasses && !itemPropsClasses) return {} as T;
+
+  const keys = new Set([...Object.keys(itemClasses || {}), ...Object.keys(itemPropsClasses || {})]);
+
+  return Array.from(keys).reduce(
+    (acc, key) => ({
+      ...acc,
+      [key]: clsx(itemClasses?.[key], itemPropsClasses?.[key]),
+    }),
+    {} as T,
+  );
+};