diff --git a/package-lock.json b/package-lock.json index e6541c1a0b..55d91bc9c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2604,19 +2604,14 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.10", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/runtime/node_modules/regenerator-runtime": { - "version": "0.14.1", - "license": "MIT" - }, "node_modules/@babel/standalone": { "version": "7.26.2", "license": "MIT", @@ -2730,6 +2725,59 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.3.0.tgz", + "integrity": "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@base-ui/utils": "0.2.6", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.4.0", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.6.tgz", + "integrity": "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@base2/pretty-print-object": { "version": "1.0.1", "license": "BSD-2-Clause" @@ -3961,22 +4009,22 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/react": { @@ -3995,12 +4043,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.4" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", @@ -4008,9 +4056,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@formatjs/ecma402-abstract": { @@ -12363,11 +12411,13 @@ } }, "node_modules/color": { - "version": "3.1.3", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", "license": "MIT", "dependencies": { - "color-convert": "^1.9.1", - "color-string": "^1.5.4" + "color-convert": "^1.9.3", + "color-string": "^1.6.0" } }, "node_modules/color-convert": { @@ -32702,6 +32752,12 @@ "dev": true, "license": "MIT" }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "license": "MIT" @@ -34853,9 +34909,9 @@ } }, "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, "node_modules/table": { @@ -37721,6 +37777,7 @@ "version": "6.116.2", "license": "MIT", "dependencies": { + "@base-ui/react": "^1.3.0", "@floating-ui/react": "^0.27.5", "@jobber/formatters": "^0.5.0", "@tanstack/react-table": "8.5.13", diff --git a/packages/components/package.json b/packages/components/package.json index 9ef11e0001..f752f59ffa 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -472,6 +472,7 @@ "dist/*" ], "dependencies": { + "@base-ui/react": "^1.3.0", "@floating-ui/react": "^0.27.5", "@jobber/formatters": "^0.5.0", "@tanstack/react-table": "8.5.13", diff --git a/packages/components/src/Menu/Menu.module.css b/packages/components/src/Menu/Menu.module.css index a2de2eee1a..a169c9b4e3 100644 --- a/packages/components/src/Menu/Menu.module.css +++ b/packages/components/src/Menu/Menu.module.css @@ -83,12 +83,16 @@ gap: var(--menu-space); } -.ariaItem > [data-menu-slot="icon"] { +.ariaItem > [data-menu-slot="icon"], +.selectableItem > [data-menu-slot="icon"], +.submenuTrigger > [data-menu-slot="icon"] { grid-column-start: 1; grid-row-start: 1; } -.ariaItem > [data-menu-slot="label"] { +.ariaItem > [data-menu-slot="label"], +.selectableItem > [data-menu-slot="label"], +.submenuTrigger > [data-menu-slot="label"] { grid-column-start: 2; } @@ -153,21 +157,16 @@ background-color: var(--color-surface--hover); } -/* Background on both legacy (:focus-visible) and RAC ([data-focused]) */ -.action[data-focused] { +/* Background highlight for Base UI menu items */ +.action[data-highlighted] { background-color: var(--color-surface--hover); } -/* Focus ring for legacy and RAC keyboard focus */ -.action[data-focus-visible] { +/* Focus ring for keyboard navigation */ +.action:focus-visible { box-shadow: var(--shadow-focus); } -/* Do not show focus ring when item is hovered (pointer interaction) */ -.action[data-hovered][data-focus-visible] { - box-shadow: none; -} - .action span { /* match appearance of Button labels */ -webkit-font-smoothing: antialiased; @@ -204,3 +203,168 @@ clip: rect(0, 0, 0, 0); white-space: nowrap; } + +/* ── Base UI composable menu (desktop) ── */ + +.menuPopup { + --menu-space: var(--space-small); + width: calc(var(--base-unit) * 12.5); + max-height: min(var(--available-height, 72vh), 72vh); + box-shadow: var(--shadow-base); + box-sizing: border-box; + padding: var(--menu-space); + border: var(--border-base) solid var(--color-border); + border-radius: var(--radius-base); + outline: none; + overflow: auto; + background-color: var(--color-surface); + opacity: 1; + transform: translateY(0); + transition: + opacity var(--timing-base) ease-out, + transform var(--timing-base) ease-out; +} + +.menuPopup[data-starting-style], +.menuPopup[data-ending-style] { + opacity: 0; +} + +.menuPopup[data-side="top"][data-starting-style], +.menuPopup[data-side="top"][data-ending-style] { + transform: translateY(10px); +} + +.menuPopup[data-side="bottom"][data-starting-style], +.menuPopup[data-side="bottom"][data-ending-style] { + transform: translateY(-10px); +} + +/* ── Base UI drawer (small screens) ── */ + +.drawerBackdrop { + position: fixed; + inset: 0; + z-index: var(--elevation-modal); + background-color: var(--color-overlay); + transition: opacity var(--timing-quick) ease-out; +} + +.drawerBackdrop[data-starting-style], +.drawerBackdrop[data-ending-style] { + opacity: 0; +} + +.drawerViewport { + display: flex; + position: fixed; + inset: 0; + z-index: var(--elevation-modal); + align-items: flex-end; +} + +.drawerPopup { + width: 100%; + transform: translateY(var(--drawer-swipe-movement-y)); + transition: transform var(--timing-slow) ease-out; +} + +.drawerPopup[data-starting-style], +.drawerPopup[data-ending-style] { + transform: translateY(100%); +} + +.drawerMenuContent { + --menu-space: var(--space-small); + max-height: 70vh; + box-shadow: var(--shadow-base); + box-sizing: border-box; + padding: var(--menu-space); + padding-bottom: calc(env(safe-area-inset-bottom) + var(--menu-space)); + border-radius: var(--radius-base) var(--radius-base) 0 0; + outline: none; + overflow-y: auto; + background-color: var(--color-surface); + -webkit-overflow-scrolling: touch; +} + +/* ── Radio / Checkbox selection items ── */ + +.radioGroup { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; +} + +.selectableItem { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + align-items: center; + gap: var(--space-small); +} + +.selectableItemIndicator { + display: flex; + grid-column-start: 3; + align-items: center; + justify-content: center; + color: var(--color-interactive); +} + +/* ── Submenu ── */ + +.submenuTrigger { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + align-items: center; + gap: var(--space-small); +} + +.submenuArrow { + display: flex; + grid-column-start: 3; + align-items: center; + justify-content: center; + color: var(--color-text--secondary); +} + +.submenuPopup { + --menu-space: var(--space-small); + width: calc(var(--base-unit) * 12.5); + max-height: min(var(--available-height, 72vh), 72vh); + box-shadow: var(--shadow-base); + box-sizing: border-box; + padding: var(--menu-space); + border: var(--border-base) solid var(--color-border); + border-radius: var(--radius-base); + outline: none; + overflow: auto; + background-color: var(--color-surface); + opacity: 1; + transform: translateX(0); + transition: + opacity var(--timing-base) ease-out, + transform var(--timing-base) ease-out; +} + +.submenuPopup[data-starting-style], +.submenuPopup[data-ending-style] { + opacity: 0; + transform: translateX(-4px); +} + +.submenuContent { + display: grid; + grid-template-columns: auto 1fr auto; +} + +/* ── Drawer drill-down back button ── */ + +.drawerBackButton { + display: flex; + grid-column: 1 / -1; + align-items: center; + gap: var(--space-small); +} diff --git a/packages/components/src/Menu/Menu.module.css.d.ts b/packages/components/src/Menu/Menu.module.css.d.ts index e4e3c30640..1598f31436 100644 --- a/packages/components/src/Menu/Menu.module.css.d.ts +++ b/packages/components/src/Menu/Menu.module.css.d.ts @@ -16,6 +16,18 @@ declare const styles: { readonly "overlay": string; readonly "fullWidth": string; readonly "screenReaderOnly": string; + readonly "menuPopup": string; + readonly "drawerBackdrop": string; + readonly "drawerViewport": string; + readonly "drawerPopup": string; + readonly "drawerMenuContent": string; + readonly "radioGroup": string; + readonly "selectableItem": string; + readonly "selectableItemIndicator": string; + readonly "submenuTrigger": string; + readonly "submenuArrow": string; + readonly "submenuPopup": string; + readonly "submenuContent": string; + readonly "drawerBackButton": string; }; export = styles; - diff --git a/packages/components/src/Menu/Menu.pom.tsx b/packages/components/src/Menu/Menu.pom.tsx index 4b7a49e0a3..1c6b2f8831 100644 --- a/packages/components/src/Menu/Menu.pom.tsx +++ b/packages/components/src/Menu/Menu.pom.tsx @@ -65,9 +65,7 @@ export async function getSectionHeader(text: string): Promise { export async function waitForAnimationToSettle(): Promise { await waitFor(() => { - const popover = document.querySelector(".react-aria-Popover"); - - return !popover || !(popover as HTMLElement).hasAttribute("data-exiting"); + expect(document.querySelector("[data-starting-style]")).toBeFalsy(); }); } @@ -76,10 +74,17 @@ export async function activateFirstItemOnly(): Promise { } export async function waitForMenuToClose(menu?: HTMLElement): Promise { - if (menu) { - await waitForElementToBeRemoved(menu); - - return; - } - await waitForElementToBeRemoved(() => screen.queryByRole("menu")); + await waitFor(() => { + // In JSDOM, CSS transitions don't run, so Base UI may not unmount + // the popup. Dispatch transitionend to trigger its cleanup. + document.querySelectorAll("[data-ending-style]").forEach(el => { + el.dispatchEvent(new Event("transitionend", { bubbles: true })); + }); + + if (menu) { + expect(menu).not.toBeInTheDocument(); + } else { + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + } + }); } diff --git a/packages/components/src/Menu/Menu.stories.tsx b/packages/components/src/Menu/Menu.stories.tsx index 076055e021..4d522d10c9 100644 --- a/packages/components/src/Menu/Menu.stories.tsx +++ b/packages/components/src/Menu/Menu.stories.tsx @@ -1,20 +1,15 @@ -import React, { useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import type { SectionProps } from "@jobber/components/Menu"; import { Menu } from "@jobber/components/Menu"; import { Button } from "@jobber/components/Button"; import { Text } from "@jobber/components/Text"; -import { Icon, type IconNames } from "@jobber/components/Icon"; -import { Chip } from "@jobber/components/Chip"; -import { Grid } from "@jobber/components/Grid"; -import { Checkbox } from "@jobber/components/Checkbox"; -import { Tooltip } from "@jobber/components/Tooltip"; -import { Popover } from "@jobber/components/Popover"; -import { Content } from "@jobber/components/Content"; +import { Icon } from "@jobber/components/Icon"; import { Emphasis } from "@jobber/components/Emphasis"; import { Typography } from "@jobber/components/Typography"; import { StatusIndicator } from "@jobber/components/StatusIndicator"; import { Heading } from "@jobber/components/Heading"; +import { Popover } from "@jobber/components/Popover"; +import { Content } from "@jobber/components/Content"; const meta = { title: "Components/Navigation/Menu", @@ -110,444 +105,551 @@ export const CustomActivator: Story = { ), }; -export const Composable: Story = { - render: () => { - const items: { - label: string; - icon: IconNames; - onClick: () => void; - destructive?: boolean; - }[] = [ - { - label: "Text Message", - icon: "sms", - onClick: () => alert("📱"), - }, - { - label: "Email", - icon: "email", - onClick: () => alert("📨"), - }, - { - label: "Delete", - icon: "trash", - destructive: true, - onClick: () => { - alert("🗑️"); - }, - }, - ]; +const ComposableTemplate = () => { + const myRef = useRef(null); + const [open, setOpen] = useState(false); - const legacyItems: SectionProps[] = [ - { - actions: [ - { - label: "Edit", - icon: "edit", - onClick: () => { - alert("✏️"); - }, - }, - ], - }, - { - header: "Send as...", - actions: [ - { - label: "Text message", - icon: "sms", - onClick: () => { - alert("📱"); - }, - }, - { - label: "Email", - icon: "email", - onClick: () => { - alert("📨"); - }, - }, - { - label: "Delete", - icon: "trash", - destructive: true, - onClick: () => { - alert("🗑️"); - }, - }, - ], - }, - ]; + const [delayedOpen, setDelayedOpen] = useState(false); - const [canView, setCanView] = useState(false); - const divRef = useRef(null); - const [showPopover, setShowPopover] = useState(true); - const [controlledOpen, setControlledOpen] = useState(false); + useEffect(() => { + setTimeout(() => { + setDelayedOpen(open); + }, 0); + }, [open]); - const fullWidthTriggerRef = useRef(null); - const [showFullWidthPopover, setShowFullWidthPopover] = useState(true); - - return ( -
- -
-

Composable with sections

- setShowPopover(false)} - > + return ( +
+
+

Basic composable

+ {delayedOpen && ( + + - Click here for new features! + Check out our new feature - - - - - - - - - Nav - - alert("Home")} textValue="Home"> - Home - - - alert("Admin")} textValue="Admin"> - Admin - - - - - - - Misc - - alert("Toggle")} - textValue="Toggle Theme" - > - Toggle Theme - - - - - -
-
-

Composable flat (controlled)

- - - - - - - - alert("")} textValue="Email"> - Email + + )} + + + + + + + + Nav + + alert("Home")} textValue="Home"> + Home + - - alert("🔋")} textValue="Text Message"> - Text Message + alert("Admin")} + textValue="Admin" + ref={myRef} + > + Admin + + + + + + + + Misc + + alert("Toggle")} + textValue="Toggle Theme" + > + Toggle Theme + - - -
+ + + +
-
-

Composable with iteration

- - - - - - {items.map(item => ( - +

Custom content

+ + + + + + + + - {item.label} - - - ))} - - -
+ Communications + + + alert("Email")} textValue="Email"> + + Email + + + + alert("Text message")} + textValue="Text Message" + > + + Text Message + + + + + + + + Featured Items + + alert("New")} textValue="Line Items"> + + Line Items + + + + + + + + Links + + + Jobber + + + + + +
+ ); +}; -
-

Composable Implementing Default

- - - - - - - - - alert("✏️")} textValue="Edit"> - Edit - - - - - - - Send as... - - {items.map(item => ( - - {item.label} - - - ))} - - - - - - - Single Tag - - } - /> - - -
-
-

Composable with Conditional Items

-
- setCanView(!canView)} - /> - - - - - - alert("Timesheets")} - textValue="Timesheets" - > - Timesheets - - - alert("Invoices")} - textValue="Invoices" - > - Invoices - - - - alert("Admin")} textValue="Admin"> - Admin - - - - - -
-
-
-

Composable with Custom Content

- - - - - +export const Composable: Story = { + render: () => , +}; + +export const RadioItems: Story = { + render: () => { + const [sort, setSort] = useState("date"); + + return ( +
+ + Selected: {sort} + + + + + + + + + Date + + + Name + + + Status + + + + +
+ ); + }, +}; + +export const RadioItemsGrouped: Story = { + render: () => { + const [sort, setSort] = useState("date_added"); + + return ( +
+ + Selected: {sort} + + + + + + + - - Communications - + By date - alert("Email")} - textValue="Email" - UNSAFE_className="custom-styles" - > - - Email (Right) - - - - alert("Text message")} - textValue="Text Message" - UNSAFE_className="custom-styles" - > - - Text Message (Right) - - - + + Date added + + + Date modified + - Featured Items + Alphabetical - alert("New")} - textValue="Line Items" - UNSAFE_className="custom-styles" - > - - Line Items - - - - - alert("Job Forms")} - textValue="Job Forms" - UNSAFE_className="custom-styles" - > - - Job Forms - - - + + Name A-Z + + + Name Z-A + - - - - Links - - - Jobber - - - - Jobber Docs - - - - - - -
+ + + + + ); + }, +}; -
-

Composable with full width trigger and Popover

- setShowFullWidthPopover(false)} - > - - Centered on the trigger - - - - { + const [sort, setSort] = useState("date"); + + return ( +
+ + Sort: {sort} + + + + + + + + + Sort by + + + + Date + + + Name + + + Status + + + + + alert("Export")} textValue="Export"> + Export + + + alert("Print")} textValue="Print"> + Print + + + + +
+ ); + }, +}; + +export const MultipleRadioGroups: Story = { + render: () => { + const [field, setField] = useState("date"); + const [direction, setDirection] = useState("asc"); + + return ( +
+ + Sort: {field}{" "} + {direction} + + + + + + + + + Sort by + + + + Date + + + Name + + + + + + + Direction + + + + Ascending + + + + Descending + + + + + + +
+ ); + }, +}; + +export const CheckboxItems: Story = { + render: () => { + const [showArchived, setShowArchived] = useState(false); + const [showDrafts, setShowDrafts] = useState(true); + const [compactView, setCompactView] = useState(false); + + return ( +
+ + Archived: {String(showArchived)}, Drafts: {String(showDrafts)}, + Compact: {String(compactView)} + + + + + + + - - - - - - Nav - - alert("Home")} textValue="Home"> - Home - - - alert("Admin")} textValue="Admin"> - Admin - - + Show archived + + + Show drafts + + + Compact view + + + +
+ ); + }, +}; + +export const SubmenuExample: Story = { + render: () => ( + + + + + + alert("Edit")} textValue="Edit"> + Edit + + + + + + Share + + + alert("Email")} textValue="Email"> + Email + + + alert("SMS")} textValue="Text Message"> + Text Message + + + alert("Link")} textValue="Copy link"> + Copy link + + + + + + alert("Delete")} + textValue="Delete" + variation="destructive" + > + Delete + + + + + ), +}; + +export const KitchenSink: Story = { + render: () => { + const [sort, setSort] = useState("date"); + const [showArchived, setShowArchived] = useState(false); + const [showDrafts, setShowDrafts] = useState(true); + + return ( +
+ + Sort: {sort} | Archived:{" "} + {String(showArchived)} | Drafts: {String(showDrafts)} + + + + + + + + + Sort by + + + + + Date + + + Name + + + Status + + + + + + + Filters + + + Show archived + + + Show drafts + + + + + + Export + + + alert("CSV")} textValue="CSV"> + CSV - - - - Misc - - alert("Toggle")} - textValue="Toggle Theme" - > - Toggle Theme - + alert("PDF")} textValue="PDF"> + PDF - - - -
+ + + + alert("Reset")} + textValue="Reset all" + variation="destructive" + > + Reset all + + + + ); }, }; -function PermissionCheck({ - children, - canView, -}: { - readonly children: React.ReactNode; - readonly canView: boolean; -}) { - if (!canView) return null; +export const NonResponsive: Story = { + render: () => { + const [sort, setSort] = useState("date"); - return <>{children}; -} + return ( +
+ + Sort: {sort} + + + + This menu always renders as a popover, even on small screens. + + + + + + + + + + Sort by + + + + Date + + + Name + + + + + alert("Export")} textValue="Export"> + Export + + + + +
+ ); + }, +}; diff --git a/packages/components/src/Menu/Menu.tsx b/packages/components/src/Menu/Menu.tsx index dde51f1196..2242e74a74 100644 --- a/packages/components/src/Menu/Menu.tsx +++ b/packages/components/src/Menu/Menu.tsx @@ -1,12 +1,14 @@ import type { MouseEvent, ReactElement } from "react"; import React, { createContext, + useCallback, useContext, useId, useMemo, useRef, useState, } from "react"; +import { createPortal } from "react-dom"; import classnames from "classnames"; import { AnimatePresence, motion } from "framer-motion"; import { @@ -25,20 +27,12 @@ import { useInteractions, useListNavigation, } from "@floating-ui/react"; -import { - Header as AriaHeader, - Menu as AriaMenu, - MenuItem as AriaMenuItem, - MenuSection as AriaMenuSection, - MenuTrigger as AriaMenuTrigger, - Popover as AriaPopover, - Pressable as AriaPressable, - Separator as AriaSeparator, -} from "react-aria-components"; +import { Menu as BaseMenu } from "@base-ui/react/menu"; +import { Drawer } from "@base-ui/react/drawer"; import styles from "./Menu.module.css"; import type { ActionProps, - AnimationState, + MenuCheckboxItemComposableProps, MenuComposableProps, MenuContentComposableProps, MenuHeaderComposableProps, @@ -46,9 +40,13 @@ import type { MenuItemIconComposableProps, MenuItemLabelComposableProps, MenuLegacyProps, - MenuMobileUnderlayProps, + MenuRadioGroupComposableProps, + MenuRadioItemComposableProps, MenuSectionComposableProps, MenuSeparatorComposableProps, + MenuSubmenuComposableProps, + MenuSubmenuContentComposableProps, + MenuSubmenuTriggerComposableProps, MenuTriggerComposableProps, SectionHeaderProps, } from "./Menu.types"; @@ -67,11 +65,6 @@ import { Icon } from "../Icon"; import { formFieldFocusAttribute } from "../FormField/hooks/useFormFieldFocus"; import { calculateMaxHeight } from "../utils/maxHeight"; -const composeOverlayVariation = { - hidden: { opacity: 0 }, - visible: { opacity: 1 }, -}; - const animationVariation = { overlayStartStop: { opacity: 0 }, startOrStop: (placement: string | undefined) => { @@ -99,8 +92,6 @@ function isLegacy( return "items" in props; } -const MotionMenu = motion.create(AriaMenu); - // Overload declarations (no bodies) export function Menu(props: MenuLegacyProps): ReactElement; export function Menu(props: MenuComposableProps): ReactElement; @@ -115,6 +106,7 @@ export function Menu( return ( >([]); const { width } = useWindowDimensions(); + console.log("width", width); const buttonID = useId(); const menuID = useId(); @@ -152,6 +145,7 @@ export function MenuLegacy({ useRefocusOnActivator(visible); const isLargeScreen = width >= SMALL_SCREEN_BREAKPOINT; + console.log("isLargeScreen", isLargeScreen); const middleware = useMemo(() => { if (isLargeScreen) { return [ @@ -475,66 +469,72 @@ function MenuPortal({ children }: { readonly children: React.ReactElement }) { return {children}; } -interface MenuAnimationContextValue { - state: AnimationState; - setState: React.Dispatch>; +interface MenuModeContextValue { + mode: "menu" | "drawer"; + closeMenu: () => void; } -const MenuAnimationContext = createContext( - null, -); - -function useMenuAnimation(): MenuAnimationContextValue { - const ctx = useContext(MenuAnimationContext); - if (!ctx) { - throw new Error("MenuAnimationContext used outside provider"); - } +const MenuModeContext = createContext({ + mode: "menu", + closeMenu: () => undefined, +}); - return ctx; +function useMenuMode(): MenuModeContextValue { + return useContext(MenuModeContext); } function MenuComposable({ children, + responsive = true, onOpenChange, open, defaultOpen, }: MenuComposableProps) { - const isInitiallyOpen = Boolean(open ?? defaultOpen); - const [animation, setAnimation] = useState( - isInitiallyOpen ? "visible" : "unmounted", + const { width } = useWindowDimensions(); + const isSmallScreen = responsive && width < SMALL_SCREEN_BREAKPOINT; + + const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false); + const isControlled = open !== undefined; + const isOpen = isControlled ? open : internalOpen; + + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (!isControlled) setInternalOpen(newOpen); + onOpenChange?.(newOpen); + }, + [isControlled, onOpenChange], ); - const derivedAnimation = getDerivedAnimation(open, animation); - return ( - - { - setAnimation(isOpen ? "visible" : "hidden"); - onOpenChange?.(isOpen); - }} - > - {children} - - + const closeMenu = useCallback( + () => handleOpenChange(false), + [handleOpenChange], ); -} -function getDerivedAnimation( - open: boolean | undefined, - animation: AnimationState, -): AnimationState { - const isControlled = open !== undefined; + const modeValue = useMemo( + () => ({ + mode: isSmallScreen ? "drawer" : "menu", + closeMenu, + }), + [isSmallScreen, closeMenu], + ); - if (!isControlled) return animation; - if (open) return "visible"; + if (isSmallScreen) { + return ( + + handleOpenChange(val)}> + {children} + + + ); + } - // When controlled and closing, allow local state to progress to "unmounted" - // so the Popover can be removed from the DOM once exit completes. - return animation === "unmounted" ? "unmounted" : "hidden"; + return ( + + handleOpenChange(val)}> + {children} + + + ); } const MenuTriggerComposable = React.forwardRef< @@ -544,14 +544,22 @@ const MenuTriggerComposable = React.forwardRef< { ariaLabel, children, UNSAFE_style, UNSAFE_className }, ref, ) { + const { mode } = useMenuMode(); const className = classnames(styles.triggerWrapper, UNSAFE_className); + const TriggerComponent = + mode === "drawer" ? Drawer.Trigger : BaseMenu.Trigger; + return ( - -
- {children} -
-
+ } + nativeButton={false} + className={className} + style={UNSAFE_style} + aria-label={ariaLabel} + > + {children} + ); }); @@ -560,78 +568,123 @@ function MenuContentComposable({ UNSAFE_style, UNSAFE_className, }: MenuContentComposableProps) { - const { state: animation, setState } = useMenuAnimation(); - const isMobile = isMobileDevice(); + const { mode } = useMenuMode(); + + if (mode === "drawer") { + return ( + + + + + + + {children} + + + + + + ); + } return ( - <> - {/* Keep Popover mounted while exiting, but do not animate it. */} - - {({ placement }) => { - const directionModifier = placement?.includes("bottom") ? -1 : 1; - const variants = isMobile - ? { - hidden: { opacity: 0, y: Y_TRANSLATION_MOBILE }, - visible: { opacity: 1, y: 0 }, - } - : { - hidden: { - opacity: 0, - y: Y_TRANSLATION_DESKTOP * directionModifier, - }, - visible: { opacity: 1, y: 0 }, - }; - - return ( - { - setState(prev => - animationState === "hidden" && prev === "hidden" - ? "unmounted" - : prev, - ); - }} - > - {children} - - ); - }} - - {isMobile && } - + + + + {children} + + + ); } -function MenuMobileUnderlay({ animation }: MenuMobileUnderlayProps) { - if (animation === "unmounted") return null; +// ── Drawer drill-down navigation ── + +interface DrawerNavigationContextValue { + activeSubmenu: { id: string; label: string } | null; + openSubmenu: (id: string, label: string) => void; + goBack: () => void; + portalContainerRef: React.RefObject; +} + +const DrawerNavigationContext = createContext({ + activeSubmenu: null, + openSubmenu: () => undefined, + goBack: () => undefined, + portalContainerRef: { current: null }, +}); + +function useDrawerNavigation(): DrawerNavigationContextValue { + return useContext(DrawerNavigationContext); +} + +function DrawerMenuContent({ + children, + UNSAFE_className, + UNSAFE_style, +}: { + readonly children: React.ReactNode; + readonly UNSAFE_className?: string; + readonly UNSAFE_style?: React.CSSProperties; +}) { + const [activeSubmenu, setActiveSubmenu] = useState<{ + id: string; + label: string; + } | null>(null); + const portalContainerRef = useRef(null); + + const openSubmenu = useCallback( + (id: string, label: string) => setActiveSubmenu({ id, label }), + [], + ); + const goBack = useCallback(() => setActiveSubmenu(null), []); + + const navValue = useMemo( + () => ({ activeSubmenu, openSubmenu, goBack, portalContainerRef }), + [activeSubmenu, openSubmenu, goBack], + ); return ( - + +
+
+ {children} +
+ {activeSubmenu && ( + + )} +
+
+ ); } @@ -639,8 +692,21 @@ function MenuSeparatorComposable({ UNSAFE_style, UNSAFE_className, }: MenuSeparatorComposableProps) { + const { mode } = useMenuMode(); + + if (mode === "drawer") { + return ( +
+ ); + } + return ( - + {children} +
+ ); + } + return ( - {children} - + ); } function MenuHeaderComposable(props: MenuHeaderComposableProps) { const { UNSAFE_style, UNSAFE_className } = props; + const { mode } = useMenuMode(); + + const className = classnames( + styles.sectionHeader, + styles.ariaSectionHeader, + UNSAFE_className, + ); + + if (mode === "drawer") { + return ( +
+ {props.children} +
+ ); + } return ( - } + className={className} style={UNSAFE_style} > {props.children} - + ); } const MenuItemComposable = React.forwardRef< - React.ElementRef, + HTMLElement, MenuItemComposableProps >(function MenuItemComposable(props: MenuItemComposableProps, ref) { const { UNSAFE_style, UNSAFE_className } = props; + const { mode, closeMenu } = useMenuMode(); const className = classnames( styles.action, @@ -694,42 +788,76 @@ const MenuItemComposable = React.forwardRef< UNSAFE_className, ); - if (props.href) { - const { href, target, rel, onClick } = props; + const itemContent = ( + + {props.children} + + ); + + if (mode === "drawer") { + if (props.href) { + return ( + } + className={className} + style={UNSAFE_style} + href={props.href} + target={props.target} + rel={props.rel} + onClick={e => { + (props.onClick as ((e: React.MouseEvent) => void) | undefined)?.(e); + closeMenu(); + }} + > + {itemContent} + + ); + } return ( - } className={className} style={UNSAFE_style} - textValue={props.textValue} - href={href} - target={target} - rel={rel} - onClick={onClick as ((e: React.MouseEvent) => void) | undefined} + type="button" + onClick={() => { + props.onClick?.(); + closeMenu(); + }} > - - {props.children} - - + {itemContent} + + ); + } + + if (props.href) { + return ( + } + className={className} + style={UNSAFE_style} + label={props.textValue} + href={props.href} + target={props.target} + rel={props.rel} + closeOnClick + onClick={props.onClick as ((e: React.MouseEvent) => void) | undefined} + > + {itemContent} + ); } return ( - } className={className} style={UNSAFE_style} - textValue={props.textValue} - onAction={() => { - // Zero-arg activation for non-link items - props.onClick?.(); - }} + label={props.textValue} + onClick={() => props.onClick?.()} > - - {props.children} - - + {itemContent} + ); }); @@ -778,12 +906,375 @@ function MenuHeaderLabel(props: { readonly children: React.ReactNode }) { return {props.children}; } +// ── Radio Group ── + +interface RadioGroupContextValue { + value: string | undefined; + onValueChange: ((value: string) => void) | undefined; +} + +const RadioGroupContext = createContext({ + value: undefined, + onValueChange: undefined, +}); + +function useRadioGroupContext(): RadioGroupContextValue { + return useContext(RadioGroupContext); +} + +function MenuRadioGroupComposable({ + value, + defaultValue, + onValueChange, + children, + UNSAFE_style, + UNSAFE_className, +}: MenuRadioGroupComposableProps) { + const { mode } = useMenuMode(); + const [internalValue, setInternalValue] = useState(defaultValue); + const isControlled = value !== undefined; + const currentValue = isControlled ? value : internalValue; + + const handleValueChange = useCallback( + (newValue: string) => { + if (!isControlled) setInternalValue(newValue); + onValueChange?.(newValue); + }, + [isControlled, onValueChange], + ); + + const className = classnames(styles.radioGroup, UNSAFE_className); + + if (mode === "drawer") { + return ( + +
+ {children} +
+
+ ); + } + + return ( + handleValueChange(val as string)} + className={className} + style={UNSAFE_style} + > + {children} + + ); +} + +// ── Radio Item ── + +function MenuRadioItemComposable({ + value, + textValue, + children, + onClick, + UNSAFE_style, + UNSAFE_className, +}: MenuRadioItemComposableProps) { + const { mode, closeMenu } = useMenuMode(); + const radioCtx = useRadioGroupContext(); + const isSelected = radioCtx.value === value; + + const className = classnames( + styles.action, + styles.selectableItem, + UNSAFE_className, + ); + + if (mode === "drawer") { + return ( + + ); + } + + return ( + onClick?.()} + > + {children} + + + + + ); +} + +// ── Checkbox Item ── + +function MenuCheckboxItemComposable({ + checked, + defaultChecked, + onCheckedChange, + textValue, + children, + onClick, + UNSAFE_style, + UNSAFE_className, +}: MenuCheckboxItemComposableProps) { + const { mode, closeMenu } = useMenuMode(); + const [internalChecked, setInternalChecked] = useState( + defaultChecked ?? false, + ); + const isControlled = checked !== undefined; + const isChecked = isControlled ? checked : internalChecked; + + const handleCheckedChange = useCallback( + (newChecked: boolean) => { + if (!isControlled) setInternalChecked(newChecked); + onCheckedChange?.(newChecked); + }, + [isControlled, onCheckedChange], + ); + + const className = classnames( + styles.action, + styles.selectableItem, + UNSAFE_className, + ); + + if (mode === "drawer") { + return ( + + ); + } + + return ( + handleCheckedChange(val)} + label={textValue} + className={className} + style={UNSAFE_style} + closeOnClick={false} + onClick={() => onClick?.()} + > + {children} + + + + + ); +} + +// ── Submenu ── + +interface DrawerSubmenuContextValue { + id: string; +} + +const DrawerSubmenuContext = createContext({ + id: "", +}); + +function useDrawerSubmenu(): DrawerSubmenuContextValue { + return useContext(DrawerSubmenuContext); +} + +function MenuSubmenuComposable({ children }: MenuSubmenuComposableProps) { + const { mode } = useMenuMode(); + + if (mode === "drawer") { + return {children}; + } + + return {children}; +} + +function DrawerSubmenuProvider({ + children, +}: { + readonly children: React.ReactNode; +}) { + const id = useId(); + const value = useMemo(() => ({ id }), [id]); + + return ( + + {children} + + ); +} + +function MenuSubmenuTriggerComposable({ + textValue, + children, + UNSAFE_style, + UNSAFE_className, +}: MenuSubmenuTriggerComposableProps) { + const { mode } = useMenuMode(); + + const className = classnames( + styles.action, + styles.submenuTrigger, + UNSAFE_className, + ); + + const arrowIndicator = ( + + + + ); + + if (mode === "drawer") { + return ( + + {children} + + ); + } + + return ( + + {children} + {arrowIndicator} + + ); +} + +function DrawerSubmenuTrigger({ + children, + textValue, + className, + style, + arrowIndicator, +}: { + readonly children: React.ReactNode; + readonly textValue: string; + readonly className: string; + readonly style?: React.CSSProperties; + readonly arrowIndicator: React.ReactNode; +}) { + const { id } = useDrawerSubmenu(); + const { openSubmenu } = useDrawerNavigation(); + + return ( + + ); +} + +function MenuSubmenuContentComposable({ + children, + UNSAFE_style, + UNSAFE_className, +}: MenuSubmenuContentComposableProps) { + const { mode } = useMenuMode(); + + if (mode === "drawer") { + return {children}; + } + + return ( + + + + {children} + + + + ); +} + +function DrawerSubmenuContentPortal({ + children, +}: { + readonly children: React.ReactNode; +}) { + const { id } = useDrawerSubmenu(); + const { activeSubmenu, portalContainerRef } = useDrawerNavigation(); + + if (activeSubmenu?.id !== id || !portalContainerRef.current) return null; + + return createPortal(children, portalContainerRef.current); +} + +Menu.Trigger = MenuTriggerComposable; +Menu.Content = MenuContentComposable; Menu.Section = MenuSectionComposable; Menu.Header = MenuHeaderComposable; Menu.Item = MenuItemComposable; -Menu.Trigger = MenuTriggerComposable; -Menu.Content = MenuContentComposable; Menu.Separator = MenuSeparatorComposable; Menu.ItemIcon = MenuItemIconComposable; Menu.ItemLabel = MenuItemLabelComposable; Menu.HeaderLabel = MenuHeaderLabel; +Menu.RadioGroup = MenuRadioGroupComposable; +Menu.RadioItem = MenuRadioItemComposable; +Menu.CheckboxItem = MenuCheckboxItemComposable; +Menu.Submenu = MenuSubmenuComposable; +Menu.SubmenuTrigger = MenuSubmenuTriggerComposable; +Menu.SubmenuContent = MenuSubmenuContentComposable; diff --git a/packages/components/src/Menu/Menu.types.ts b/packages/components/src/Menu/Menu.types.ts index 9800c0f1e7..1b731a5b53 100644 --- a/packages/components/src/Menu/Menu.types.ts +++ b/packages/components/src/Menu/Menu.types.ts @@ -1,15 +1,8 @@ import type { IconColorNames, IconNames } from "@jobber/design"; import type React from "react"; -import type { - CSSProperties, - ComponentProps, - ReactElement, - ReactNode, -} from "react"; -import type { Pressable as AriaPressable } from "react-aria-components"; +import type { CSSProperties, ReactElement, ReactNode } from "react"; import type { IconProps } from "../Icon"; -type PressableChild = ComponentProps["children"]; export interface MenuLegacyProps extends MenuBaseProps { /** * Custom menu activator. If this is not provided a default [… More] will be used. @@ -52,11 +45,17 @@ interface MenuBaseProps { export interface MenuComposableProps extends MenuBaseProps { /** * Composable children-based API. - * The first child must be the Menu.Trigger - * The second child must be the Menu.Content + * Must include a Menu.Trigger and Menu.Content. */ readonly children: ReactNode; + /** + * Whether the menu adapts to screen size, rendering as a drawer on small screens. + * When false, always renders as a positioned popover. + * @default true + */ + readonly responsive?: boolean; + /** * Used to make the menu a Controlled Component. */ @@ -220,14 +219,112 @@ export interface MenuTriggerComposableProps extends UnsafeProps { * If you want to access the open event, use the onOpenChange on the Menu component. * If it does not have an interactive role, or a focus style it will have issues. */ - readonly children: PressableChild; + readonly children: ReactNode; } export interface MenuSeparatorComposableProps extends UnsafeProps {} -export type AnimationState = "unmounted" | "hidden" | "visible"; -export interface MenuMobileUnderlayProps { - readonly animation: AnimationState; +export interface MenuRadioGroupComposableProps extends UnsafeProps { + /** + * The controlled value of the selected radio item. + */ + readonly value?: string; + + /** + * The initial value when uncontrolled. + */ + readonly defaultValue?: string; + + /** + * Callback fired when the selected value changes. + */ + readonly onValueChange?: (value: string) => void; + + /** + * Radio item children. + */ + readonly children: ReactNode; +} + +export interface MenuRadioItemComposableProps extends UnsafeProps { + /** + * The value of this radio item. Must be unique within the RadioGroup. + */ + readonly value: string; + + /** + * String representation of the item's content for typeahead. + */ + readonly textValue: string; + + /** + * Item content (e.g., Menu.ItemLabel, Menu.ItemIcon). + */ + readonly children: ReactNode; + + /** + * Optional callback fired when the item is clicked. + */ + readonly onClick?: (event?: React.MouseEvent) => void; +} + +export interface MenuCheckboxItemComposableProps extends UnsafeProps { + /** + * Whether the checkbox item is currently checked. + */ + readonly checked?: boolean; + + /** + * The initial checked state when uncontrolled. + */ + readonly defaultChecked?: boolean; + + /** + * Callback fired when the checked state changes. + */ + readonly onCheckedChange?: (checked: boolean) => void; + + /** + * String representation of the item's content for typeahead. + */ + readonly textValue: string; + + /** + * Item content (e.g., Menu.ItemLabel, Menu.ItemIcon). + */ + readonly children: ReactNode; + + /** + * Optional callback fired when the item is clicked. + */ + readonly onClick?: (event?: React.MouseEvent) => void; +} + +export interface MenuSubmenuComposableProps extends UnsafeProps { + /** + * Composable children. Must include a Menu.SubmenuTrigger and Menu.SubmenuContent. + */ + readonly children: ReactNode; +} + +export interface MenuSubmenuTriggerComposableProps extends UnsafeProps { + /** + * String representation of the trigger for typeahead. + */ + readonly textValue: string; + + /** + * Trigger content (e.g., Menu.ItemIcon, Menu.ItemLabel). + * A chevron arrow is appended automatically. + */ + readonly children: ReactNode; +} + +export interface MenuSubmenuContentComposableProps extends UnsafeProps { + /** + * Submenu items rendered inside the submenu popup. + */ + readonly children: ReactNode; } export interface MenuItemLabelComposableProps { diff --git a/packages/components/src/Menu/__tests__/Menu.composable.test.tsx b/packages/components/src/Menu/__tests__/Menu.composable.test.tsx index c3cbaf09da..822038fb25 100644 --- a/packages/components/src/Menu/__tests__/Menu.composable.test.tsx +++ b/packages/components/src/Menu/__tests__/Menu.composable.test.tsx @@ -82,6 +82,7 @@ describe("Menu (composable API)", () => { await waitFor(() => expect(screen.getByRole("menu")).toBeVisible()); }); }); + describe("Trigger content with Chip", () => { it("calls onOpenChange when the menu is opened", async () => { const onOpenChange = jest.fn(); @@ -99,6 +100,7 @@ describe("Menu (composable API)", () => { }); }); }); + describe("Controlled Component", () => { it("renders the menu in the open state", async () => { render(); @@ -116,11 +118,9 @@ describe("Menu (composable API)", () => { const onOpenChange = jest.fn(); render(); - // Starts open expect(await screen.findByRole("menu")).toBeVisible(); const menuRef = screen.getByRole("menu"); - // Interact with the first item -> should request close await POM.activateFirstItemOnly(); expect(onOpenChange).toHaveBeenCalledWith(false); await POM.waitForMenuToClose(menuRef); @@ -204,7 +204,6 @@ describe("Menu (composable API)", () => { describe("Link integration and event mapping", () => { it("calls onClick with a MouseEvent for link items", async () => { - // Avoid href link navigation in test environment const onItemClick = jest.fn((e: React.MouseEvent) => e.preventDefault()); render();