diff --git a/package-lock.json b/package-lock.json index f7b50b2..573c441 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "@storybook/react": "^8.4.2", "@storybook/react-vite": "^8.4.2", "@storybook/test": "^8.4.2", - "@types/lodash": "^4.17.13", + "@types/lodash.throttle": "^4.1.9", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", @@ -43,6 +43,7 @@ "globals": "^15.11.0", "husky": "^9.1.7", "lint-staged": "^15.2.10", + "lodash.throttle": "^4.1.1", "postcss": "^8.4.47", "prettier": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.8", @@ -3571,6 +3572,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash.throttle": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/lodash.throttle/-/lodash.throttle-4.1.9.tgz", + "integrity": "sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -7455,7 +7466,8 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash-es": { "version": "4.17.21", @@ -7539,6 +7551,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", diff --git a/package.json b/package.json index 2565c41..6214f87 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@storybook/react": "^8.4.2", "@storybook/react-vite": "^8.4.2", "@storybook/test": "^8.4.2", - "@types/lodash": "^4.17.13", + "@types/lodash.throttle": "^4.1.9", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", @@ -69,6 +69,7 @@ "globals": "^15.11.0", "husky": "^9.1.7", "lint-staged": "^15.2.10", + "lodash.throttle": "^4.1.1", "postcss": "^8.4.47", "prettier": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.8", @@ -96,9 +97,9 @@ "dependencies": { "@hookform/resolvers": "^3.9.1", "@popperjs/core": "^2.11.8", + "decimal.js-light": "^2.5.1", "react-hook-form": "^7.54.0", "react-popper": "^2.3.0", - "decimal.js-light": "^2.5.1", "tw-colors": "^3.3.2" } -} \ No newline at end of file +} diff --git a/src/components/Menu/Menu.stories.tsx b/src/components/Menu/Menu.stories.tsx new file mode 100644 index 0000000..c23bb5e --- /dev/null +++ b/src/components/Menu/Menu.stories.tsx @@ -0,0 +1,131 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { Menu } from "./Menu"; +import { MenuItem } from "./MenuItem"; +import { SubMenu } from "./SubMenu"; +import { SubMenuItem } from "./SubMenuItem"; +import { Button } from "../Button"; +import { Text } from "../Text"; + +const meta: Meta = { + title: "Components/Menu", + component: Menu, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: "A menu component with support for nested submenus.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const SettingIcon = () => ( + + + +); + +const ThemeIcon = () => ( + + + + +); + +const ReportABugIcon = () => ( + + + + +); + +const ChevronRightIcon = () => ( + + + +); + +export const Default: Story = { + render: () => { + const [selectedTheme, setSelectedTheme] = useState("auto"); + const themes = [ + { id: "auto", label: "Auto", description: undefined }, + { id: "light", label: "Light", description: undefined }, + { id: "dark", label: "Dark", description: undefined }, + ]; + return ( +
+ + + + } + > + + Settings + +
+ } + suffix={} + className="bg-secondary-highlight md:bg-transparent" + > +
+ {themes.map((theme) => ( + { + setSelectedTheme(theme.id); + console.log(`Theme selected: ${theme.id}`); + }} + /> + ))} +
+
+ } + suffix={} + className="mb-4 bg-secondary-highlight md:mb-0 md:bg-transparent" + /> + } + className="bg-secondary-highlight md:bg-transparent" + /> + } + className="bg-secondary-highlight md:bg-transparent" + /> +
+
+ +
+
+
+ ); + }, +}; diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx new file mode 100644 index 0000000..c3618c8 --- /dev/null +++ b/src/components/Menu/Menu.tsx @@ -0,0 +1,92 @@ +import React, { useState, useRef } from "react"; +import { twJoin } from "tailwind-merge"; + +import { Popover } from "../Popover"; +import { MenuProvider } from "./MenuContext"; +import { MenuDrawer } from "./MenuDrawer"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { Placement } from "@popperjs/core"; + +interface MenuProps { + children: React.ReactNode; + trigger?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: Placement; + offset?: [number, number]; + className?: string; +} + +export const Menu: React.FC = ({ + children, + trigger, + open: controlledOpen, + onOpenChange, + placement = "bottom-end", + offset = [0, 8], + className, +}) => { + const [internalOpen, setInternalOpen] = useState(false); + const triggerRef = useRef(null); + const isMobile = useIsMobile(); + + const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; + const setIsOpen = (open: boolean) => { + if (controlledOpen === undefined) { + setInternalOpen(open); + } + onOpenChange?.(open); + }; + + const onClose = () => setIsOpen(false); + + const menuContent =
{children}
; + + const triggerElement = trigger || ( + + ); + + return ( + +
+ {React.isValidElement(triggerElement) && + React.cloneElement(triggerElement as React.ReactElement, { + onClick: () => setIsOpen(!isOpen), + "aria-haspopup": "true", + "aria-expanded": isOpen, + })} +
+ + {isMobile ? ( + + {menuContent} + + ) : ( + + {menuContent} + + )} +
+ ); +}; diff --git a/src/components/Menu/MenuContext.tsx b/src/components/Menu/MenuContext.tsx new file mode 100644 index 0000000..b0ee8eb --- /dev/null +++ b/src/components/Menu/MenuContext.tsx @@ -0,0 +1,25 @@ +import React, { createContext, useContext } from "react"; + +interface MenuContextValue { + isOpen: boolean; + setIsOpen: (open: boolean) => void; + onClose: () => void; + isMobile: boolean; +} + +const MenuContext = createContext(undefined); + +export const useMenuContext = () => { + const context = useContext(MenuContext); + if (!context && process.env.NODE_ENV === "development") { + console.warn("useMenuContext must be used within a Menu component"); + } + return context; +}; + +export const MenuProvider: React.FC<{ + children: React.ReactNode; + value: MenuContextValue; +}> = ({ children, value }) => { + return {children}; +}; diff --git a/src/components/Menu/MenuDrawer.tsx b/src/components/Menu/MenuDrawer.tsx new file mode 100644 index 0000000..97b92c2 --- /dev/null +++ b/src/components/Menu/MenuDrawer.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { twJoin } from "tailwind-merge"; + +interface MenuDrawerProps { + isOpen: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; + className?: string; + titleAlign?: "left" | "center" | "right"; + backIcon?: React.ReactNode; + fullHeight?: boolean; + fullWidth?: boolean; + showBackButton?: boolean; + showDivider?: boolean; + onBackdropClick?: () => void; +} + +export const MenuDrawer: React.FC = ({ + isOpen, + onClose, + title, + children, + className = "", + titleAlign = "left", + backIcon, + fullHeight = false, + fullWidth = false, + showBackButton = true, + showDivider = true, + onBackdropClick, +}) => { + const titleAlignment = { + left: "text-left", + center: "text-center", + right: "text-right", + }; + + return ( + <> + {fullHeight && isOpen && onBackdropClick && ( +
+ )} + +
+ {/* Header */} + {(showBackButton || title) && ( +
+ {showBackButton && ( + + )} + {title && ( +

+ {title} +

+ )} +
+ )} + +
{children}
+
+ + ); +}; diff --git a/src/components/Menu/MenuItem.tsx b/src/components/Menu/MenuItem.tsx new file mode 100644 index 0000000..5f18e48 --- /dev/null +++ b/src/components/Menu/MenuItem.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { twJoin } from "tailwind-merge"; + +import { useMenuContext } from "./MenuContext"; + +interface MenuItemProps { + /** Leading icon or element */ + icon?: React.ReactNode; + /** Primary text content */ + name: string; + /** Secondary text or description */ + description?: string; + /** Click handler */ + onClick?: () => void; + /** Disabled state */ + disabled?: boolean; + /** Additional CSS classes */ + className?: string; + /** Custom content (overrides default layout) */ + children?: React.ReactNode; + /** Trailing element (e.g., chevron, badge, switch) */ + suffix?: React.ReactNode; + /** Selected/active state */ + selected?: boolean; + /** Role attribute for accessibility */ + role?: string; +} + +export const MenuItem: React.FC = ({ + icon, + name, + description, + onClick, + disabled = false, + className, + children, + suffix, + selected = false, + role = "menuitem", +}) => { + const menuContext = useMenuContext(); + + const handleClick = () => { + if (disabled || !onClick) return; + onClick(); + // Close menu after click unless it has a suffix (indicating a submenu) + if (menuContext && !suffix) { + menuContext.onClose(); + } + }; + + // If children are provided, render them instead of the default layout + if (children) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/src/components/Menu/SubMenu.tsx b/src/components/Menu/SubMenu.tsx new file mode 100644 index 0000000..8f01441 --- /dev/null +++ b/src/components/Menu/SubMenu.tsx @@ -0,0 +1,106 @@ +import React, { useState } from "react"; +import { twJoin } from "tailwind-merge"; +import { MenuDrawer } from "./MenuDrawer"; +import { useMenuContext } from "./MenuContext"; + +interface SubMenuProps { + /** Nested submenu content */ + children: React.ReactNode; + /** Primary text to display in the menu item */ + name?: string; // This is the recommended property for the primary text in menu items. Use this instead of older alternatives. + /** Secondary text for the menu item */ + description?: string; // This is the recommended property for secondary text in menu items. Use this instead of older alternatives. + /** Leading icon */ + icon?: React.ReactNode; + /** Icon used for the back button inside the drawer */ + backIcon?: React.ReactNode; + /** Trailing element shown in the menu item (defaults to chevron) */ + suffix?: React.ReactNode; + /** Extra classes for the menu item button */ + className?: string; + /** Extra classes for the drawer */ + contentClassName?: string; + /** Title alignment in the drawer header */ + titleAlign?: "left" | "center" | "right"; +} + +export const SubMenu: React.FC = ({ + children, + name, + description, + icon, + backIcon, + suffix, + className, + contentClassName, + titleAlign = "left", +}) => { + const [isOpen, setIsOpen] = useState(false); + const menuContext = useMenuContext(); + + const defaultBackIcon = ( + + + + ); + + const chevronRightIcon = ( + + + + ); + + return ( + <> + {/* Menu Item Button */} + + + {/* Sliding Drawer */} + setIsOpen(false)} + title={name || ""} + titleAlign={titleAlign} + backIcon={backIcon || defaultBackIcon} + fullHeight={menuContext?.isMobile} + fullWidth={menuContext?.isMobile} + onBackdropClick={menuContext?.isMobile ? undefined : () => setIsOpen(false)} + className={twJoin(menuContext?.isMobile ? "z-60" : "z-50", contentClassName)} + > + {children} + + + ); +}; diff --git a/src/components/Menu/SubMenuItem.tsx b/src/components/Menu/SubMenuItem.tsx new file mode 100644 index 0000000..c43890b --- /dev/null +++ b/src/components/Menu/SubMenuItem.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { twJoin } from "tailwind-merge"; + +interface SubMenuItemProps { + /** Primary text content */ + label: string; + /** Secondary text or description */ + description?: string; + /** Click handler */ + onClick?: () => void; + /** Selected/active state */ + selected?: boolean; + /** Additional CSS classes */ + className?: string; + /** Custom suffix element (defaults to checkmark when selected) */ + suffix?: React.ReactNode; + /** Disabled state */ + disabled?: boolean; +} + +export const SubMenuItem: React.FC = ({ + label, + description, + onClick, + selected = false, + className, + suffix, + disabled = false, +}) => { + const defaultCheckmark = ( + + + + ); + + return ( + + ); +}; diff --git a/src/components/Menu/index.ts b/src/components/Menu/index.ts new file mode 100644 index 0000000..48cac6c --- /dev/null +++ b/src/components/Menu/index.ts @@ -0,0 +1,9 @@ +export { useMenuContext } from "./MenuContext"; + +export { Menu } from "./Menu"; +export { MenuItem } from "./MenuItem"; + +export { MenuDrawer } from "./MenuDrawer"; + +export { SubMenu } from "./SubMenu"; +export { SubMenuItem } from "./SubMenuItem"; diff --git a/src/hooks/useIsMobile.ts b/src/hooks/useIsMobile.ts new file mode 100644 index 0000000..7527baa --- /dev/null +++ b/src/hooks/useIsMobile.ts @@ -0,0 +1,39 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import throttle from "lodash.throttle"; + +/** + * Hook to detect if the viewport is in mobile size + * @param breakpoint - The breakpoint in pixels (default: 768) + * @param throttleMs - The throttle delay in milliseconds (default: 100) + * @returns boolean indicating if viewport is mobile size + */ +export function useIsMobile(breakpoint: number = 768, throttleMs: number = 100): boolean { + const [isMobile, setIsMobile] = useState(() => { + // Initial state based on current window width + if (typeof window !== "undefined") { + return window.innerWidth < breakpoint; + } + return false; + }); + + const throttledCheckMobile = useRef>(); + + const checkMobile = useCallback(() => { + setIsMobile(window.innerWidth < breakpoint); + }, [breakpoint]); + + useEffect(() => { + throttledCheckMobile.current = throttle(checkMobile, throttleMs); + checkMobile(); + window.addEventListener("resize", throttledCheckMobile.current); + + return () => { + if (throttledCheckMobile.current) { + window.removeEventListener("resize", throttledCheckMobile.current); + throttledCheckMobile.current.cancel(); + } + }; + }, [checkMobile, throttleMs]); + + return isMobile; +} diff --git a/src/hooks/useTableScroll.ts b/src/hooks/useTableScroll.ts index 11aa643..6ab556c 100644 --- a/src/hooks/useTableScroll.ts +++ b/src/hooks/useTableScroll.ts @@ -1,5 +1,5 @@ import { RefObject, useEffect, useState } from "react"; -import lodash from "lodash"; +import throttle from "lodash.throttle"; interface UseTableScrollOptions { onLoadMore?: () => void; @@ -14,7 +14,7 @@ export function useTableScroll( const [isScrolledTop, setIsScrolledTop] = useState(false); useEffect(() => { - const handleScroll = lodash.throttle((e: Event) => { + const handleScroll = throttle((e: Event) => { const target = e.target as HTMLDivElement; setIsScrolledTop(target.scrollTop > 0); diff --git a/src/index.tsx b/src/index.tsx index 3fc3eae..c2db8fb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,6 +19,7 @@ export * from "./components/Badge"; export * from "./components/SubSection"; export * from "./components/FinalityProviderLogo"; export * from "./components/CounterButton"; +export * from "./components/Menu"; export * from "./widgets/form/Form"; export * from "./widgets/form/NumberField";