From 5a7b938d60053d7078b12c83bc313a0a1b216bb8 Mon Sep 17 00:00:00 2001 From: ZakaryH Date: Mon, 23 Mar 2026 13:03:30 -0700 Subject: [PATCH 1/8] add baseui --- package-lock.json | 109 +++++++++++++++++++++++-------- packages/components/package.json | 13 ++-- 2 files changed, 89 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index e6541c1a0b..9cb3c6195d 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": { @@ -32702,6 +32750,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 +34907,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 +37775,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..5591e611e4 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -472,26 +472,27 @@ "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", + "@tanstack/react-table": "^8", "@types/color": "^3.0.1", "@types/lodash": "^4.14.136", "@types/react-router": "5.1.7", "@types/react-router-dom": "^5.3.3", "axios": "^1.11.0", - "classnames": "^2.3.2", - "color": "^3.1.2", - "filesize": "^6.1.0", + "classnames": "^2", + "color": "^4", + "filesize": "^6", "framer-motion": "^11.11.12", - "lodash": "^4.17.21", + "lodash": "^4", "react-aria-components": "^1.11.0", "react-countdown": "^2.3.2", "react-datepicker": "^8.7.0", "react-dropzone": "^11.0.2", "react-hook-form": "^7.52.0", "react-markdown": "^10.1.0", - "react-router-dom": "^5.3.4", + "react-router-dom": "^6", "ts-xor": "^1.0.8" }, "devDependencies": { From 5c90756ee063546346eeca66bce77dcbbbaf56dd Mon Sep 17 00:00:00 2001 From: ZakaryH Date: Mon, 23 Mar 2026 16:07:37 -0700 Subject: [PATCH 2/8] swap Menu internals with BaseUI --- packages/components/src/Menu/Menu.module.css | 97 ++++- .../components/src/Menu/Menu.module.css.d.ts | 6 +- packages/components/src/Menu/Menu.pom.tsx | 23 +- packages/components/src/Menu/Menu.tsx | 385 ++++++++++-------- packages/components/src/Menu/Menu.types.ts | 16 +- 5 files changed, 323 insertions(+), 204 deletions(-) diff --git a/packages/components/src/Menu/Menu.module.css b/packages/components/src/Menu/Menu.module.css index a2de2eee1a..2aed2f6562 100644 --- a/packages/components/src/Menu/Menu.module.css +++ b/packages/components/src/Menu/Menu.module.css @@ -153,21 +153,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 +199,87 @@ 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; +} diff --git a/packages/components/src/Menu/Menu.module.css.d.ts b/packages/components/src/Menu/Menu.module.css.d.ts index e4e3c30640..9325de5aa5 100644 --- a/packages/components/src/Menu/Menu.module.css.d.ts +++ b/packages/components/src/Menu/Menu.module.css.d.ts @@ -16,6 +16,10 @@ 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; }; 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.tsx b/packages/components/src/Menu/Menu.tsx index dde51f1196..d4bf78671c 100644 --- a/packages/components/src/Menu/Menu.tsx +++ b/packages/components/src/Menu/Menu.tsx @@ -1,6 +1,7 @@ import type { MouseEvent, ReactElement } from "react"; import React, { createContext, + useCallback, useContext, useId, useMemo, @@ -25,20 +26,11 @@ 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, MenuComposableProps, MenuContentComposableProps, MenuHeaderComposableProps, @@ -46,7 +38,6 @@ import type { MenuItemIconComposableProps, MenuItemLabelComposableProps, MenuLegacyProps, - MenuMobileUnderlayProps, MenuSectionComposableProps, MenuSeparatorComposableProps, MenuTriggerComposableProps, @@ -67,11 +58,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 +85,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; @@ -138,6 +122,7 @@ export function MenuLegacy({ const listRef = useRef>([]); const { width } = useWindowDimensions(); + console.log("width", width); const buttonID = useId(); const menuID = useId(); @@ -152,6 +137,7 @@ export function MenuLegacy({ useRefocusOnActivator(visible); const isLargeScreen = width >= SMALL_SCREEN_BREAKPOINT; + console.log("isLargeScreen", isLargeScreen); const middleware = useMemo(() => { if (isLargeScreen) { return [ @@ -475,22 +461,18 @@ 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({ @@ -499,42 +481,51 @@ function MenuComposable({ open, defaultOpen, }: MenuComposableProps) { - const isInitiallyOpen = Boolean(open ?? defaultOpen); - const [animation, setAnimation] = useState( - isInitiallyOpen ? "visible" : "unmounted", + const { width } = useWindowDimensions(); + const isSmallScreen = 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 +535,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 +559,47 @@ function MenuContentComposable({ UNSAFE_style, UNSAFE_className, }: MenuContentComposableProps) { - const { state: animation, setState } = useMenuAnimation(); - const isMobile = isMobileDevice(); + const { mode } = useMenuMode(); - 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 && } - - ); -} - -function MenuMobileUnderlay({ animation }: MenuMobileUnderlayProps) { - if (animation === "unmounted") return null; + if (mode === "drawer") { + return ( + + + + + +
+ {children} +
+
+
+
+
+ ); + } return ( - + + + + {children} + + + ); } @@ -639,8 +607,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 +703,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} + ); }); diff --git a/packages/components/src/Menu/Menu.types.ts b/packages/components/src/Menu/Menu.types.ts index 9800c0f1e7..742aaadaa2 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. @@ -220,16 +213,11 @@ 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 MenuItemLabelComposableProps { /** * Item label content. From d76fb49e20e0969ccee12a80107a14aa582aab10 Mon Sep 17 00:00:00 2001 From: ZakaryH Date: Wed, 25 Mar 2026 11:45:18 -0700 Subject: [PATCH 3/8] fix deps --- package-lock.json | 8 +++++--- packages/components/package.json | 12 ++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9cb3c6195d..55d91bc9c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12411,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": { diff --git a/packages/components/package.json b/packages/components/package.json index 5591e611e4..f752f59ffa 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -475,24 +475,24 @@ "@base-ui/react": "^1.3.0", "@floating-ui/react": "^0.27.5", "@jobber/formatters": "^0.5.0", - "@tanstack/react-table": "^8", + "@tanstack/react-table": "8.5.13", "@types/color": "^3.0.1", "@types/lodash": "^4.14.136", "@types/react-router": "5.1.7", "@types/react-router-dom": "^5.3.3", "axios": "^1.11.0", - "classnames": "^2", - "color": "^4", - "filesize": "^6", + "classnames": "^2.3.2", + "color": "^3.1.2", + "filesize": "^6.1.0", "framer-motion": "^11.11.12", - "lodash": "^4", + "lodash": "^4.17.21", "react-aria-components": "^1.11.0", "react-countdown": "^2.3.2", "react-datepicker": "^8.7.0", "react-dropzone": "^11.0.2", "react-hook-form": "^7.52.0", "react-markdown": "^10.1.0", - "react-router-dom": "^6", + "react-router-dom": "^5.3.4", "ts-xor": "^1.0.8" }, "devDependencies": { From b094089ed9bbf6a72e02f8bc37fe506dd0f903c4 Mon Sep 17 00:00:00 2001 From: ZakaryH Date: Wed, 25 Mar 2026 13:52:02 -0700 Subject: [PATCH 4/8] add more sub components --- packages/components/src/Menu/Menu.module.css | 64 ++ .../components/src/Menu/Menu.module.css.d.ts | 6 + packages/components/src/Menu/Menu.stories.tsx | 932 ++++++++++-------- packages/components/src/Menu/Menu.tsx | 509 ++++++++-- packages/components/src/Menu/Menu.types.ts | 141 ++- .../Menu/__tests__/Menu.composable.test.tsx | 311 +++--- packages/components/src/Page/Page.tsx | 10 +- .../site/src/components/VersionSelector.tsx | 32 +- 8 files changed, 1294 insertions(+), 711 deletions(-) diff --git a/packages/components/src/Menu/Menu.module.css b/packages/components/src/Menu/Menu.module.css index 2aed2f6562..004d83d493 100644 --- a/packages/components/src/Menu/Menu.module.css +++ b/packages/components/src/Menu/Menu.module.css @@ -283,3 +283,67 @@ 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); +} + +.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; +} diff --git a/packages/components/src/Menu/Menu.module.css.d.ts b/packages/components/src/Menu/Menu.module.css.d.ts index 9325de5aa5..aa4ac666bb 100644 --- a/packages/components/src/Menu/Menu.module.css.d.ts +++ b/packages/components/src/Menu/Menu.module.css.d.ts @@ -21,5 +21,11 @@ declare const styles: { readonly "drawerViewport": string; readonly "drawerPopup": string; readonly "drawerMenuContent": string; + readonly "radioGroup": string; + readonly "selectableItem": string; + readonly "selectableItemIndicator": string; + readonly "submenuTrigger": string; + readonly "submenuPopup": string; + readonly "submenuContent": string; }; export = styles; diff --git a/packages/components/src/Menu/Menu.stories.tsx b/packages/components/src/Menu/Menu.stories.tsx index 076055e021..5e8cb7a1dd 100644 --- a/packages/components/src/Menu/Menu.stories.tsx +++ b/packages/components/src/Menu/Menu.stories.tsx @@ -1,16 +1,9 @@ -import React, { useRef, useState } from "react"; +import React, { 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"; @@ -111,443 +104,524 @@ export const CustomActivator: Story = { }; export const Composable: Story = { + render: () => ( +
+
+

Basic composable

+ + Actions + + } + > + + + Nav + + alert("Home")} textValue="Home"> + Home + + + alert("Admin")} textValue="Admin"> + Admin + + + + + + + + Misc + + alert("Toggle")} textValue="Toggle Theme"> + Toggle Theme + + + + +
+ +
+

Custom content

+ + Custom + + } + > + + + + 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 + + + +
+
+ ), +}; + +export const RadioItems: 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 [sort, setSort] = useState("date"); - 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("🗑️"); - }, - }, - ], - }, - ]; + return ( +
+ + Selected: {sort} + + + Sort by + + } + > + + + Date + + + Name + + + Status + + + +
+ ); + }, +}; - const [canView, setCanView] = useState(false); - const divRef = useRef(null); - const [showPopover, setShowPopover] = useState(true); - const [controlledOpen, setControlledOpen] = useState(false); +export const RadioItemsGrouped: Story = { + render: () => { + const [sort, setSort] = useState("date_added"); - const fullWidthTriggerRef = useRef(null); - const [showFullWidthPopover, setShowFullWidthPopover] = useState(true); + return ( +
+ + Selected: {sort} + + + Sort by + + } + > + + + + By date + + + Date added + + + Date modified + + + + + + Alphabetical + + + Name A-Z + + + Name Z-A + + + + +
+ ); + }, +}; + +export const MixedRadioAndActions: Story = { + render: () => { + const [sort, setSort] = useState("date"); return (
- -
-

Composable with sections

- setShowPopover(false)} - > - - Click here for new features! - - - - - - - - - - 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 - - - alert("🔋")} textValue="Text Message"> - Text Message - - - -
+ > + + + Sort by + + + + Date + + + Name + + + Status + + + + + alert("Export")} textValue="Export"> + Export + + + alert("Print")} textValue="Print"> + Print + + + +
+ ); + }, +}; -
-

Composable with iteration

- - - - - - {items.map(item => ( - - {item.label} - - - ))} - - -
+export const MultipleRadioGroups: Story = { + render: () => { + const [field, setField] = useState("date"); + const [direction, setDirection] = useState("asc"); -
-

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

- - - - - - - - - Communications - - - alert("Email")} - textValue="Email" - UNSAFE_className="custom-styles" - > - - Email (Right) - - - - alert("Text message")} - textValue="Text Message" - UNSAFE_className="custom-styles" - > - - Text Message (Right) - - - - - - - - Featured Items - - alert("New")} - textValue="Line Items" - UNSAFE_className="custom-styles" - > - - Line Items - - - + return ( +
+ + Sort: {field}{" "} + {direction} + + + Sort options + + } + > + + + Sort by + + + + Date + + + Name + + + + + + + Direction + + + + Ascending + + + + Descending + + + + + +
+ ); + }, +}; - alert("Job Forms")} - textValue="Job Forms" - UNSAFE_className="custom-styles" - > - - Job Forms - - - -
- - - - Links - - - Jobber - - - - Jobber Docs - - - - -
-
-
+export const CheckboxItems: Story = { + render: () => { + const [showArchived, setShowArchived] = useState(false); + const [showDrafts, setShowDrafts] = useState(true); + const [compactView, setCompactView] = useState(false); -
-

Composable with full width trigger and Popover

- setShowFullWidthPopover(false)} + return ( +
+ + Archived: {String(showArchived)}, Drafts: {String(showDrafts)}, + Compact: {String(compactView)} + + + Settings + + } + > + - - Centered on the trigger - - - - Show archived + + + Show drafts + + + Compact view + + +
+ ); + }, +}; + +export const SubmenuExample: Story = { + render: () => ( + + Actions + + } + > + 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)} + + + View + + } + > + + + Sort by + + + + Date + + + Name + + + Status + + + + + + + Filters + + - - - - - - Nav - - alert("Home")} textValue="Home"> - Home - - - alert("Admin")} textValue="Admin"> - Admin - - - - - - - Misc - - alert("Toggle")} - textValue="Toggle Theme" - > - Toggle Theme - - - - - -
+ Show archived + + + Show drafts + + + + + Export + + + } + > + alert("CSV")} textValue="CSV"> + CSV + + 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 PopoverOnly: Story = { + render: () => { + const [sort, setSort] = useState("date"); - return <>{children}; -} + return ( +
+ + Sort: {sort} + + + + Menu.Popover never swaps to a drawer on small screens. + + + + Sort (Popover only) + + } + > + + + 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 d4bf78671c..390530f5b2 100644 --- a/packages/components/src/Menu/Menu.tsx +++ b/packages/components/src/Menu/Menu.tsx @@ -31,16 +31,19 @@ import { Drawer } from "@base-ui/react/drawer"; import styles from "./Menu.module.css"; import type { ActionProps, + MenuCheckboxItemComposableProps, MenuComposableProps, - MenuContentComposableProps, MenuHeaderComposableProps, MenuItemComposableProps, MenuItemIconComposableProps, MenuItemLabelComposableProps, MenuLegacyProps, + MenuPopoverProps, + MenuRadioGroupComposableProps, + MenuRadioItemComposableProps, MenuSectionComposableProps, MenuSeparatorComposableProps, - MenuTriggerComposableProps, + MenuSubmenuComposableProps, SectionHeaderProps, } from "./Menu.types"; import { @@ -98,13 +101,15 @@ export function Menu( } return ( - {props.children} - + ); } @@ -475,7 +480,9 @@ function useMenuMode(): MenuModeContextValue { return useContext(MenuModeContext); } -function MenuComposable({ +function MenuResponsive({ + trigger, + ariaLabel, children, onOpenChange, open, @@ -513,7 +520,31 @@ function MenuComposable({ return ( handleOpenChange(val)}> - {children} + } + nativeButton={false} + className={styles.triggerWrapper} + aria-label={ariaLabel} + > + {trigger} + + + + + + +
+ {children} +
+
+
+
+
); @@ -522,84 +553,90 @@ function MenuComposable({ return ( handleOpenChange(val)}> - {children} + } + nativeButton={false} + className={styles.triggerWrapper} + aria-label={ariaLabel} + > + {trigger} + + + + + {children} + + + ); } -const MenuTriggerComposable = React.forwardRef< - HTMLDivElement, - MenuTriggerComposableProps ->(function MenuTriggerComposable( - { ariaLabel, children, UNSAFE_style, UNSAFE_className }, - ref, -) { - const { mode } = useMenuMode(); - const className = classnames(styles.triggerWrapper, UNSAFE_className); +// ── Menu.Popover (popover-only, no responsive drawer swap) ── - const TriggerComponent = - mode === "drawer" ? Drawer.Trigger : BaseMenu.Trigger; +function MenuPopoverComposable({ + trigger, + ariaLabel, + children, + onOpenChange, + open, + defaultOpen, +}: MenuPopoverProps) { + const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false); + const isControlled = open !== undefined; + const isOpen = isControlled ? open : internalOpen; - return ( - } - nativeButton={false} - className={className} - style={UNSAFE_style} - aria-label={ariaLabel} - > - {children} - + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (!isControlled) setInternalOpen(newOpen); + onOpenChange?.(newOpen); + }, + [isControlled, onOpenChange], ); -}); -function MenuContentComposable({ - children, - UNSAFE_style, - UNSAFE_className, -}: MenuContentComposableProps) { - const { mode } = useMenuMode(); + const closeMenu = useCallback( + () => handleOpenChange(false), + [handleOpenChange], + ); - if (mode === "drawer") { - return ( - - - - - -
- {children} -
-
-
-
-
- ); - } + const modeValue = useMemo( + () => ({ mode: "menu" as const, closeMenu }), + [closeMenu], + ); return ( - - - + handleOpenChange(val)}> + } + nativeButton={false} + className={styles.triggerWrapper} + aria-label={ariaLabel} > - {children} - - - + {trigger} + + + + + {children} + + + + + ); } @@ -821,12 +858,338 @@ 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 ── + +function MenuSubmenuComposable({ + trigger, + textValue, + children, + UNSAFE_style, + UNSAFE_className, +}: MenuSubmenuComposableProps) { + const { mode } = useMenuMode(); + + const triggerClassName = classnames( + styles.action, + styles.submenuTrigger, + UNSAFE_className, + ); + + if (mode === "drawer") { + return ( + + {children} + + ); + } + + return ( + + + {trigger} + + + + + {children} + + + + + ); +} + +function DrawerSubmenu({ + trigger, + textValue, + triggerClassName, + triggerStyle, + children, +}: { + readonly trigger: React.ReactNode; + readonly textValue: string; + readonly triggerClassName: string; + readonly triggerStyle?: React.CSSProperties; + readonly children: React.ReactNode; +}) { + const [open, setOpen] = useState(false); + + if (open) { + return ( +
+ +
+ {children} +
+
+ ); + } + + return ( + + ); +} + 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; + +const MenuPopover = Object.assign(MenuPopoverComposable, { + Section: MenuSectionComposable, + Header: MenuHeaderComposable, + Item: MenuItemComposable, + Separator: MenuSeparatorComposable, + ItemIcon: MenuItemIconComposable, + ItemLabel: MenuItemLabelComposable, + HeaderLabel: MenuHeaderLabel, + RadioGroup: MenuRadioGroupComposable, + RadioItem: MenuRadioItemComposable, + CheckboxItem: MenuCheckboxItemComposable, + Submenu: MenuSubmenuComposable, +}); + +Menu.Popover = MenuPopover; diff --git a/packages/components/src/Menu/Menu.types.ts b/packages/components/src/Menu/Menu.types.ts index 742aaadaa2..11d4aec268 100644 --- a/packages/components/src/Menu/Menu.types.ts +++ b/packages/components/src/Menu/Menu.types.ts @@ -44,9 +44,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 + * The trigger element that opens the menu. + */ + readonly trigger: ReactElement; + + /** + * Accessible label for the menu. + */ + readonly ariaLabel: string; + + /** + * Menu item children (Menu.Item, Menu.Section, Menu.Separator, etc.). */ readonly children: ReactNode; @@ -68,6 +76,39 @@ export interface MenuComposableProps extends MenuBaseProps { readonly onOpenChange?: (isOpen: boolean) => void; } +export interface MenuPopoverProps { + /** + * The trigger element that opens the popover menu. + */ + readonly trigger: ReactElement; + + /** + * Accessible label for the menu. + */ + readonly ariaLabel: string; + + /** + * Menu item children (Menu.Popover.Item, Menu.Popover.Section, etc.). + */ + readonly children: ReactNode; + + /** + * Used to make the menu a Controlled Component. + */ + readonly open?: boolean; + + /** + * This sets the default open state of the menu. + * By default the menu is closed. + */ + readonly defaultOpen?: boolean; + + /** + * Callback when the menu is opened or closed + */ + readonly onOpenChange?: (isOpen: boolean) => void; +} + /** * Backwards-compatible props for the items-based Menu API. * Existing imports of `MenuProps` will continue to refer to the legacy shape. @@ -218,6 +259,100 @@ export interface MenuTriggerComposableProps extends UnsafeProps { export interface MenuSeparatorComposableProps extends UnsafeProps {} +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 { + /** + * Content rendered inside the submenu trigger item. + * The library wraps this in the appropriate trigger element. + */ + readonly trigger: ReactNode; + + /** + * String representation of the trigger for typeahead. + */ + readonly textValue: string; + + /** + * Submenu items rendered inside the submenu popup. + */ + readonly children: ReactNode; +} + export interface MenuItemLabelComposableProps { /** * Item label content. diff --git a/packages/components/src/Menu/__tests__/Menu.composable.test.tsx b/packages/components/src/Menu/__tests__/Menu.composable.test.tsx index c3cbaf09da..a2f4dd4c2e 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); @@ -141,15 +141,6 @@ describe("Menu (composable API)", () => { }); describe("UNSAFE_className and UNSAFE_style", () => { - it("applies UNSAFE props on Menu.Content", async () => { - render(); - await POM.openWithClick("Menu"); - - const menu = screen.getByRole("menu"); - expect(menu).toHaveClass("unsafe-menu"); - expect(menu).toHaveStyle("border: 1px solid red"); - }); - it("applies UNSAFE props on Menu.Section", async () => { render(); await POM.openWithClick("Menu"); @@ -190,21 +181,10 @@ describe("Menu (composable API)", () => { expect(separator).toHaveClass("unsafe-sep"); expect(separator).toHaveStyle("height: 7px"); }); - - it("applies UNSAFE props on Menu.Trigger", async () => { - render(); - - const triggerWrapper = POM.getTriggerUnsafeElement("Menu"); - - expect(triggerWrapper).toBeInTheDocument(); - expect(triggerWrapper).toHaveClass("full-width-trigger"); - expect(triggerWrapper).toHaveStyle("display: block"); - }); }); 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(); @@ -230,15 +210,10 @@ describe("Menu (composable API)", () => { it("calls onClick without an event for non-link items", async () => { const onItem = jest.fn(); render( - - - ); } @@ -373,19 +340,19 @@ function ControlledMenuHarness(props: { function TestIconTriggerMenu() { return ( - - + - - - - - One - - - + } + > + + + One + + ); } @@ -394,142 +361,114 @@ function TestChipTriggerMenu(props: { readonly onOpenChange?: (isOpen: boolean) => void; }) { return ( - - - - - - - One - - + } + onOpenChange={props.onOpenChange} + > + + One + ); } function TestUnsafePropsMenu() { return ( - - }> + - ); } function TextCustomContentMenu() { return ( - - - - } - > - - - Nav - - alert("Home")} textValue="Home"> - Home - - - alert("Admin")} textValue="Admin"> - Admin - - - - - - - - Misc - - alert("Toggle")} textValue="Toggle Theme"> - Toggle Theme - - - + + + + + Nav + + alert("Home")} textValue="Home"> + Home + + + alert("Admin")} textValue="Admin"> + Admin + + + + + + + + Misc + + alert("Toggle")} + textValue="Toggle Theme" + > + Toggle Theme + + + +

Custom content

- + - } - > - - - + + + + + Communications + + + alert("Email")} textValue="Email"> + + Email + + + + alert("Text message")} + textValue="Text Message" > - 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 - - + + Text Message + + + + + + + + Featured Items + + alert("New")} textValue="Line Items"> + + Line Items + + + + + + + + Links + + + Jobber + + +
@@ -221,25 +224,25 @@ export const RadioItems: Story = { Selected: {sort} - + - } - > - - - Date - - - Name - - - Status - - + + + + + Date + + + Name + + + Status + + + ); @@ -255,39 +258,39 @@ export const RadioItemsGrouped: Story = { Selected: {sort} - + - } - > - - - - By date - - - Date added - - - Date modified - - - - - - Alphabetical - - - Name A-Z - - - Name Z-A - - - + + + + + + By date + + + Date added + + + Date modified + + + + + + Alphabetical + + + Name A-Z + + + Name Z-A + + + + ); @@ -303,39 +306,39 @@ export const MixedRadioAndActions: Story = { Sort: {sort} - + - } - > - - - Sort by - - - - Date - - - Name - - - Status - - - - - alert("Export")} textValue="Export"> - Export - - - alert("Print")} textValue="Print"> - Print - - + + + + + Sort by + + + + Date + + + Name + + + Status + + + + + alert("Export")} textValue="Export"> + Export + + + alert("Print")} textValue="Print"> + Print + + + ); @@ -353,43 +356,43 @@ export const MultipleRadioGroups: Story = { Sort: {field}{" "} {direction} - + - } - > - - - Sort by - - - - Date - - - Name - - - - - - - Direction - - - - Ascending - - - - Descending - - - - + + + + + Sort by + + + + Date + + + Name + + + + + + + Direction + + + + Ascending + + + + Descending + + + + + ); @@ -408,35 +411,35 @@ export const CheckboxItems: Story = { Archived: {String(showArchived)}, Drafts: {String(showDrafts)}, Compact: {String(compactView)} - + - } - > - - Show archived - - - Show drafts - - - Compact view - + + + + Show archived + + + Show drafts + + + Compact view + + ); @@ -445,49 +448,49 @@ export const CheckboxItems: Story = { export const SubmenuExample: Story = { render: () => ( - + - } - > - alert("Edit")} textValue="Edit"> - Edit - - - - Share - - - } - > - alert("Email")} textValue="Email"> - Email - - - alert("SMS")} textValue="Text Message"> - Text Message - + + + alert("Edit")} textValue="Edit"> + Edit + - alert("Link")} textValue="Copy link"> - Copy link - + + 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 + - - - alert("Delete")} - textValue="Delete" - variation="destructive" - > - Delete - - + ), }; @@ -504,83 +507,83 @@ export const KitchenSink: Story = { Sort: {sort} | Archived:{" "} {String(showArchived)} | Drafts: {String(showDrafts)} - + - } - > - - - Sort by - - - - Date - - - Name - - - Status - - - - - - - Filters - - + + + + Sort by + + + + Date + + + Name + + + Status + + + + + + + Filters + + + Show archived + + + Show drafts + + + + + Export + + + } > - Show archived - - alert("CSV")} textValue="CSV"> + CSV + + alert("PDF")} textValue="PDF"> + PDF + + + + alert("Reset")} + textValue="Reset all" + variation="destructive" > - Show drafts - - - - - Export - - - } - > - alert("CSV")} textValue="CSV"> - CSV + Reset all + - alert("PDF")} textValue="PDF"> - PDF - - - - alert("Reset")} - textValue="Reset all" - variation="destructive" - > - Reset all - - + ); }, }; -export const PopoverOnly: Story = { +export const NonResponsive: Story = { render: () => { const [sort, setSort] = useState("date"); @@ -591,36 +594,36 @@ export const PopoverOnly: Story = { - Menu.Popover never swaps to a drawer on small screens. + This menu always renders as a popover, even on small screens. - + - } - > - - - Sort by - - - - Date - - - Name - - - - - alert("Export")} textValue="Export"> - Export - - - + + + + + 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 390530f5b2..a3fa5d6bc8 100644 --- a/packages/components/src/Menu/Menu.tsx +++ b/packages/components/src/Menu/Menu.tsx @@ -33,17 +33,18 @@ import type { ActionProps, MenuCheckboxItemComposableProps, MenuComposableProps, + MenuContentComposableProps, MenuHeaderComposableProps, MenuItemComposableProps, MenuItemIconComposableProps, MenuItemLabelComposableProps, MenuLegacyProps, - MenuPopoverProps, MenuRadioGroupComposableProps, MenuRadioItemComposableProps, MenuSectionComposableProps, MenuSeparatorComposableProps, MenuSubmenuComposableProps, + MenuTriggerComposableProps, SectionHeaderProps, } from "./Menu.types"; import { @@ -101,15 +102,14 @@ export function Menu( } return ( - {props.children} - + ); } @@ -480,16 +480,15 @@ function useMenuMode(): MenuModeContextValue { return useContext(MenuModeContext); } -function MenuResponsive({ - trigger, - ariaLabel, +function MenuComposable({ children, + responsive = true, onOpenChange, open, defaultOpen, }: MenuComposableProps) { const { width } = useWindowDimensions(); - const isSmallScreen = width < SMALL_SCREEN_BREAKPOINT; + const isSmallScreen = responsive && width < SMALL_SCREEN_BREAKPOINT; const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false); const isControlled = open !== undefined; @@ -520,31 +519,7 @@ function MenuResponsive({ return ( handleOpenChange(val)}> - } - nativeButton={false} - className={styles.triggerWrapper} - aria-label={ariaLabel} - > - {trigger} - - - - - - -
- {children} -
-
-
-
-
+ {children}
); @@ -553,90 +528,84 @@ function MenuResponsive({ return ( handleOpenChange(val)}> - } - nativeButton={false} - className={styles.triggerWrapper} - aria-label={ariaLabel} - > - {trigger} - - - - - {children} - - - + {children} ); } -// ── Menu.Popover (popover-only, no responsive drawer swap) ── +const MenuTriggerComposable = React.forwardRef< + HTMLDivElement, + MenuTriggerComposableProps +>(function MenuTriggerComposable( + { ariaLabel, children, UNSAFE_style, UNSAFE_className }, + ref, +) { + const { mode } = useMenuMode(); + const className = classnames(styles.triggerWrapper, UNSAFE_className); -function MenuPopoverComposable({ - trigger, - ariaLabel, - children, - onOpenChange, - open, - defaultOpen, -}: MenuPopoverProps) { - const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false); - const isControlled = open !== undefined; - const isOpen = isControlled ? open : internalOpen; + const TriggerComponent = + mode === "drawer" ? Drawer.Trigger : BaseMenu.Trigger; - const handleOpenChange = useCallback( - (newOpen: boolean) => { - if (!isControlled) setInternalOpen(newOpen); - onOpenChange?.(newOpen); - }, - [isControlled, onOpenChange], + return ( + } + nativeButton={false} + className={className} + style={UNSAFE_style} + aria-label={ariaLabel} + > + {children} + ); +}); - const closeMenu = useCallback( - () => handleOpenChange(false), - [handleOpenChange], - ); +function MenuContentComposable({ + children, + UNSAFE_style, + UNSAFE_className, +}: MenuContentComposableProps) { + const { mode } = useMenuMode(); - const modeValue = useMemo( - () => ({ mode: "menu" as const, closeMenu }), - [closeMenu], - ); + if (mode === "drawer") { + return ( + + + + + +
+ {children} +
+
+
+
+
+ ); + } return ( - - handleOpenChange(val)}> - } - nativeButton={false} - className={styles.triggerWrapper} - aria-label={ariaLabel} + + + - {trigger} - - - - - {children} - - - - - + {children} + + + ); } @@ -1166,6 +1135,8 @@ function DrawerSubmenu({ ); } +Menu.Trigger = MenuTriggerComposable; +Menu.Content = MenuContentComposable; Menu.Section = MenuSectionComposable; Menu.Header = MenuHeaderComposable; Menu.Item = MenuItemComposable; @@ -1177,19 +1148,3 @@ Menu.RadioGroup = MenuRadioGroupComposable; Menu.RadioItem = MenuRadioItemComposable; Menu.CheckboxItem = MenuCheckboxItemComposable; Menu.Submenu = MenuSubmenuComposable; - -const MenuPopover = Object.assign(MenuPopoverComposable, { - Section: MenuSectionComposable, - Header: MenuHeaderComposable, - Item: MenuItemComposable, - Separator: MenuSeparatorComposable, - ItemIcon: MenuItemIconComposable, - ItemLabel: MenuItemLabelComposable, - HeaderLabel: MenuHeaderLabel, - RadioGroup: MenuRadioGroupComposable, - RadioItem: MenuRadioItemComposable, - CheckboxItem: MenuCheckboxItemComposable, - Submenu: MenuSubmenuComposable, -}); - -Menu.Popover = MenuPopover; diff --git a/packages/components/src/Menu/Menu.types.ts b/packages/components/src/Menu/Menu.types.ts index 11d4aec268..2810a1fb5d 100644 --- a/packages/components/src/Menu/Menu.types.ts +++ b/packages/components/src/Menu/Menu.types.ts @@ -44,53 +44,17 @@ interface MenuBaseProps { export interface MenuComposableProps extends MenuBaseProps { /** - * The trigger element that opens the menu. - */ - readonly trigger: ReactElement; - - /** - * Accessible label for the menu. - */ - readonly ariaLabel: string; - - /** - * Menu item children (Menu.Item, Menu.Section, Menu.Separator, etc.). + * Composable children-based API. + * Must include a Menu.Trigger and Menu.Content. */ readonly children: ReactNode; /** - * Used to make the menu a Controlled Component. - */ - readonly open?: boolean; - - /** - * This sets the default open state of the menu. - * By default the menu is closed. - * For use when the component is being used as an Uncontrolled Component. - */ - readonly defaultOpen?: boolean; - - /** - * Callback when the menu is opened or closed - */ - readonly onOpenChange?: (isOpen: boolean) => void; -} - -export interface MenuPopoverProps { - /** - * The trigger element that opens the popover menu. + * 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 trigger: ReactElement; - - /** - * Accessible label for the menu. - */ - readonly ariaLabel: string; - - /** - * Menu item children (Menu.Popover.Item, Menu.Popover.Section, etc.). - */ - readonly children: ReactNode; + readonly responsive?: boolean; /** * Used to make the menu a Controlled Component. @@ -100,6 +64,7 @@ export interface MenuPopoverProps { /** * This sets the default open state of the menu. * By default the menu is closed. + * For use when the component is being used as an Uncontrolled Component. */ readonly defaultOpen?: boolean; diff --git a/packages/components/src/Menu/__tests__/Menu.composable.test.tsx b/packages/components/src/Menu/__tests__/Menu.composable.test.tsx index a2f4dd4c2e..822038fb25 100644 --- a/packages/components/src/Menu/__tests__/Menu.composable.test.tsx +++ b/packages/components/src/Menu/__tests__/Menu.composable.test.tsx @@ -141,6 +141,15 @@ describe("Menu (composable API)", () => { }); describe("UNSAFE_className and UNSAFE_style", () => { + it("applies UNSAFE props on Menu.Content", async () => { + render(); + await POM.openWithClick("Menu"); + + const menu = screen.getByRole("menu"); + expect(menu).toHaveClass("unsafe-menu"); + expect(menu).toHaveStyle("border: 1px solid red"); + }); + it("applies UNSAFE props on Menu.Section", async () => { render(); await POM.openWithClick("Menu"); @@ -181,6 +190,16 @@ describe("Menu (composable API)", () => { expect(separator).toHaveClass("unsafe-sep"); expect(separator).toHaveStyle("height: 7px"); }); + + it("applies UNSAFE props on Menu.Trigger", async () => { + render(); + + const triggerWrapper = POM.getTriggerUnsafeElement("Menu"); + + expect(triggerWrapper).toBeInTheDocument(); + expect(triggerWrapper).toHaveClass("full-width-trigger"); + expect(triggerWrapper).toHaveStyle("display: block"); + }); }); describe("Link integration and event mapping", () => { @@ -210,10 +229,15 @@ describe("Menu (composable API)", () => { it("calls onClick without an event for non-link items", async () => { const onItem = jest.fn(); render( - }> - - Open - + + + , ); @@ -275,17 +299,22 @@ function TestLinkMenu(props: { readonly withRef?: React.Ref; }) { return ( - }> - void) | undefined - } - ref={props.withRef} - > - Jobs - + + + ); } @@ -298,26 +327,29 @@ function TestSectionMenu(props: { }) { return ( } onOpenChange={props.onOpenChange} open={props.open} defaultOpen={props.defaultOpen} > - - - Section Header - - - Open - - - - - - Two - - + + ); } @@ -340,19 +372,19 @@ function ControlledMenuHarness(props: { function TestIconTriggerMenu() { return ( - + - } - > - - - One - - + + + + + One + + + ); } @@ -361,114 +393,142 @@ function TestChipTriggerMenu(props: { readonly onOpenChange?: (isOpen: boolean) => void; }) { return ( - } - onOpenChange={props.onOpenChange} - > - - One - + + + + + + + One + + ); } function TestUnsafePropsMenu() { return ( - }> - + - - Section Header - - + + + - Open - - - + + Section Header + + + Open + + + + ); } function TextCustomContentMenu() { return ( - }> - - -
Header
-
- -
Email
-
- -
Text message
-
- -
Phone
-
-
+ + + ); } function TestDefaultMenuWithIcons() { return ( - }> - - - Send as... - - - Email - - - - - - - Delete - - - + + + ); } function TestMenuWithReactNodeItemLabel() { return ( - }> - - - Send as... - - - - Email - - - - - - - - - Delete - - - - + + + ); } diff --git a/packages/components/src/Page/Page.tsx b/packages/components/src/Page/Page.tsx index a6fe254381..da5b173220 100644 --- a/packages/components/src/Page/Page.tsx +++ b/packages/components/src/Page/Page.tsx @@ -343,13 +343,11 @@ function PageMenu({ return (
- +
); diff --git a/packages/site/src/components/VersionSelector.tsx b/packages/site/src/components/VersionSelector.tsx index 474641261f..be0045cf7f 100644 --- a/packages/site/src/components/VersionSelector.tsx +++ b/packages/site/src/components/VersionSelector.tsx @@ -25,23 +25,23 @@ export const VersionSelector = ({ return ( - + - } - > - {availableVersions.map(version => { - return ( - onVersionChange(version)} - > - {versionLabelMap[version]} - - ); - })} + + + {availableVersions.map(version => { + return ( + onVersionChange(version)} + > + {versionLabelMap[version]} + + ); + })} + ); From ba9ffb919e4edee42a6a70c9a6a5cf0e414cbc1e Mon Sep 17 00:00:00 2001 From: ZakaryH Date: Thu, 26 Mar 2026 17:08:03 -0700 Subject: [PATCH 6/8] fix grid placement system --- packages/components/src/Menu/Menu.module.css | 16 ++++++++++++++-- .../components/src/Menu/Menu.module.css.d.ts | 1 + packages/components/src/Menu/Menu.stories.tsx | 10 +++------- packages/components/src/Menu/Menu.tsx | 11 +++++++++++ 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/components/src/Menu/Menu.module.css b/packages/components/src/Menu/Menu.module.css index 004d83d493..2a70981b7d 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; } @@ -318,6 +322,14 @@ 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); diff --git a/packages/components/src/Menu/Menu.module.css.d.ts b/packages/components/src/Menu/Menu.module.css.d.ts index aa4ac666bb..f41dfefb99 100644 --- a/packages/components/src/Menu/Menu.module.css.d.ts +++ b/packages/components/src/Menu/Menu.module.css.d.ts @@ -25,6 +25,7 @@ declare const styles: { readonly "selectableItem": string; readonly "selectableItemIndicator": string; readonly "submenuTrigger": string; + readonly "submenuArrow": string; readonly "submenuPopup": string; readonly "submenuContent": string; }; diff --git a/packages/components/src/Menu/Menu.stories.tsx b/packages/components/src/Menu/Menu.stories.tsx index 1d4115c076..51ffb0aa23 100644 --- a/packages/components/src/Menu/Menu.stories.tsx +++ b/packages/components/src/Menu/Menu.stories.tsx @@ -463,8 +463,8 @@ export const SubmenuExample: Story = { textValue="Share" trigger={ <> + Share - } > @@ -520,6 +520,7 @@ export const KitchenSink: Story = { + Date @@ -553,12 +554,7 @@ export const KitchenSink: Story = { - Export - - - } + trigger={Export} > alert("CSV")} textValue="CSV"> CSV diff --git a/packages/components/src/Menu/Menu.tsx b/packages/components/src/Menu/Menu.tsx index a3fa5d6bc8..f4cd389b73 100644 --- a/packages/components/src/Menu/Menu.tsx +++ b/packages/components/src/Menu/Menu.tsx @@ -1066,6 +1066,9 @@ function MenuSubmenuComposable({ style={UNSAFE_style} > {trigger} + + + + + + ); + if (open) { return (
@@ -1110,6 +1119,7 @@ function DrawerSubmenu({ onClick={() => setOpen(false)} > {trigger} + {arrowIndicator}
setOpen(true)} > {trigger} + {arrowIndicator} ); } From 0dcb5e7565885d56edaad3356b98dc374e3efbc8 Mon Sep 17 00:00:00 2001 From: ZakaryH Date: Mon, 30 Mar 2026 15:36:03 -0700 Subject: [PATCH 7/8] alter submenu API --- packages/components/src/Menu/Menu.stories.tsx | 103 +++++--- packages/components/src/Menu/Menu.tsx | 226 ++++++++++++------ packages/components/src/Menu/Menu.types.ts | 15 +- 3 files changed, 229 insertions(+), 115 deletions(-) diff --git a/packages/components/src/Menu/Menu.stories.tsx b/packages/components/src/Menu/Menu.stories.tsx index 51ffb0aa23..4d522d10c9 100644 --- a/packages/components/src/Menu/Menu.stories.tsx +++ b/packages/components/src/Menu/Menu.stories.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { Menu } from "@jobber/components/Menu"; import { Button } from "@jobber/components/Button"; @@ -8,6 +8,8 @@ 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", @@ -103,12 +105,31 @@ export const CustomActivator: Story = { ), }; -export const Composable: Story = { - render: () => ( +const ComposableTemplate = () => { + const myRef = useRef(null); + const [open, setOpen] = useState(false); + + const [delayedOpen, setDelayedOpen] = useState(false); + + useEffect(() => { + setTimeout(() => { + setDelayedOpen(open); + }, 0); + }, [open]); + + return (

Basic composable

- + {delayedOpen && ( + + + + Check out our new feature + + + )} +
- ), + ); +}; + +export const Composable: Story = { + render: () => , }; export const RadioItems: Story = { @@ -459,27 +488,25 @@ export const SubmenuExample: Story = { Edit - - - Share - - } - > - alert("Email")} textValue="Email"> - Email - - - alert("SMS")} textValue="Text Message"> - Text Message - - - alert("Link")} textValue="Copy link"> - Copy link - - + + + + Share + + + alert("Email")} textValue="Email"> + Email + + + alert("SMS")} textValue="Text Message"> + Text Message + + + alert("Link")} textValue="Copy link"> + Copy link + + + - Export} - > - alert("CSV")} textValue="CSV"> - CSV - - alert("PDF")} textValue="PDF"> - PDF - + + + Export + + + alert("CSV")} textValue="CSV"> + CSV + + alert("PDF")} textValue="PDF"> + PDF + + void; +} + +const DrawerSubmenuContext = createContext({ + open: false, + toggle: () => undefined, +}); + +function useDrawerSubmenu(): DrawerSubmenuContextValue { + return useContext(DrawerSubmenuContext); +} + function MenuSubmenuComposable({ - trigger, - textValue, children, UNSAFE_style, UNSAFE_className, }: MenuSubmenuComposableProps) { const { mode } = useMenuMode(); - const triggerClassName = classnames( - styles.action, - styles.submenuTrigger, - UNSAFE_className, - ); - if (mode === "drawer") { return ( - {children} - + ); } - return ( - - - {trigger} - - - - - - - - {children} - - - - - ); + return {children}; } -function DrawerSubmenu({ - trigger, - textValue, - triggerClassName, - triggerStyle, +function DrawerSubmenuProvider({ children, + UNSAFE_style, + UNSAFE_className, }: { - readonly trigger: React.ReactNode; - readonly textValue: string; - readonly triggerClassName: string; - readonly triggerStyle?: React.CSSProperties; readonly children: React.ReactNode; + readonly UNSAFE_style?: React.CSSProperties; + readonly UNSAFE_className?: string; }) { const [open, setOpen] = useState(false); + const toggle = useCallback(() => setOpen(prev => !prev), []); + const value = useMemo(() => ({ open, toggle }), [open, toggle]); + + 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 = ( @@ -1108,44 +1109,117 @@ function DrawerSubmenu({ ); - if (open) { + if (mode === "drawer") { return ( -
- -
- {children} -
-
+ + {children} + ); } + return ( + + {children} + {arrowIndicator} + + ); +} + +function DrawerSubmenuTrigger({ + children, + className, + style, + arrowIndicator, +}: { + readonly children: React.ReactNode; + readonly className: string; + readonly style?: React.CSSProperties; + readonly arrowIndicator: React.ReactNode; +}) { + const { open, toggle } = useDrawerSubmenu(); + return ( ); } +function MenuSubmenuContentComposable({ + children, + UNSAFE_style, + UNSAFE_className, +}: MenuSubmenuContentComposableProps) { + const { mode } = useMenuMode(); + + if (mode === "drawer") { + return ( + + {children} + + ); + } + + return ( + + + + {children} + + + + ); +} + +function DrawerSubmenuContent({ + children, + UNSAFE_style, + UNSAFE_className, +}: { + readonly children: React.ReactNode; + readonly UNSAFE_style?: React.CSSProperties; + readonly UNSAFE_className?: string; +}) { + const { open } = useDrawerSubmenu(); + + if (!open) return null; + + return ( +
+ {children} +
+ ); +} + Menu.Trigger = MenuTriggerComposable; Menu.Content = MenuContentComposable; Menu.Section = MenuSectionComposable; @@ -1159,3 +1233,5 @@ 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 2810a1fb5d..1b731a5b53 100644 --- a/packages/components/src/Menu/Menu.types.ts +++ b/packages/components/src/Menu/Menu.types.ts @@ -302,16 +302,25 @@ export interface MenuCheckboxItemComposableProps extends UnsafeProps { export interface MenuSubmenuComposableProps extends UnsafeProps { /** - * Content rendered inside the submenu trigger item. - * The library wraps this in the appropriate trigger element. + * Composable children. Must include a Menu.SubmenuTrigger and Menu.SubmenuContent. */ - readonly trigger: ReactNode; + 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. */ From d6bd76f4f2011ee1403517a98e104f22bc457ada Mon Sep 17 00:00:00 2001 From: ZakaryH Date: Mon, 30 Mar 2026 15:59:00 -0700 Subject: [PATCH 8/8] fix Drawer submenu layout, implement 'drill down' UX --- packages/components/src/Menu/Menu.module.css | 9 + .../components/src/Menu/Menu.module.css.d.ts | 1 + packages/components/src/Menu/Menu.tsx | 167 +++++++++++------- 3 files changed, 115 insertions(+), 62 deletions(-) diff --git a/packages/components/src/Menu/Menu.module.css b/packages/components/src/Menu/Menu.module.css index 2a70981b7d..a169c9b4e3 100644 --- a/packages/components/src/Menu/Menu.module.css +++ b/packages/components/src/Menu/Menu.module.css @@ -359,3 +359,12 @@ 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 f41dfefb99..1598f31436 100644 --- a/packages/components/src/Menu/Menu.module.css.d.ts +++ b/packages/components/src/Menu/Menu.module.css.d.ts @@ -28,5 +28,6 @@ declare const styles: { readonly "submenuArrow": string; readonly "submenuPopup": string; readonly "submenuContent": string; + readonly "drawerBackButton": string; }; export = styles; diff --git a/packages/components/src/Menu/Menu.tsx b/packages/components/src/Menu/Menu.tsx index bda95c69e4..2242e74a74 100644 --- a/packages/components/src/Menu/Menu.tsx +++ b/packages/components/src/Menu/Menu.tsx @@ -8,6 +8,7 @@ import React, { useRef, useState, } from "react"; +import { createPortal } from "react-dom"; import classnames from "classnames"; import { AnimatePresence, motion } from "framer-motion"; import { @@ -576,16 +577,12 @@ function MenuContentComposable({ -
{children} -
+
@@ -611,6 +608,86 @@ function MenuContentComposable({ ); } +// ── 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 && ( + + )} +
+
+ + ); +} + function MenuSeparatorComposable({ UNSAFE_style, UNSAFE_className, @@ -1033,35 +1110,22 @@ function MenuCheckboxItemComposable({ // ── Submenu ── interface DrawerSubmenuContextValue { - open: boolean; - toggle: () => void; + id: string; } const DrawerSubmenuContext = createContext({ - open: false, - toggle: () => undefined, + id: "", }); function useDrawerSubmenu(): DrawerSubmenuContextValue { return useContext(DrawerSubmenuContext); } -function MenuSubmenuComposable({ - children, - UNSAFE_style, - UNSAFE_className, -}: MenuSubmenuComposableProps) { +function MenuSubmenuComposable({ children }: MenuSubmenuComposableProps) { const { mode } = useMenuMode(); if (mode === "drawer") { - return ( - - {children} - - ); + return {children}; } return {children}; @@ -1069,22 +1133,15 @@ function MenuSubmenuComposable({ function DrawerSubmenuProvider({ children, - UNSAFE_style, - UNSAFE_className, }: { readonly children: React.ReactNode; - readonly UNSAFE_style?: React.CSSProperties; - readonly UNSAFE_className?: string; }) { - const [open, setOpen] = useState(false); - const toggle = useCallback(() => setOpen(prev => !prev), []); - const value = useMemo(() => ({ open, toggle }), [open, toggle]); + const id = useId(); + const value = useMemo(() => ({ id }), [id]); return ( -
- {children} -
+ {children}
); } @@ -1112,6 +1169,7 @@ function MenuSubmenuTriggerComposable({ if (mode === "drawer") { return ( openSubmenu(id, textValue)} > {children} {arrowIndicator} @@ -1169,14 +1229,7 @@ function MenuSubmenuContentComposable({ const { mode } = useMenuMode(); if (mode === "drawer") { - return ( - - {children} - - ); + return {children}; } return ( @@ -1197,27 +1250,17 @@ function MenuSubmenuContentComposable({ ); } -function DrawerSubmenuContent({ +function DrawerSubmenuContentPortal({ children, - UNSAFE_style, - UNSAFE_className, }: { readonly children: React.ReactNode; - readonly UNSAFE_style?: React.CSSProperties; - readonly UNSAFE_className?: string; }) { - const { open } = useDrawerSubmenu(); + const { id } = useDrawerSubmenu(); + const { activeSubmenu, portalContainerRef } = useDrawerNavigation(); - if (!open) return null; + if (activeSubmenu?.id !== id || !portalContainerRef.current) return null; - return ( -
- {children} -
- ); + return createPortal(children, portalContainerRef.current); } Menu.Trigger = MenuTriggerComposable;