diff --git a/src/components/Menu/MenuContext.tsx b/src/components/Menu/MenuContext.tsx index b0ee8eb..1b93d7d 100644 --- a/src/components/Menu/MenuContext.tsx +++ b/src/components/Menu/MenuContext.tsx @@ -12,7 +12,7 @@ 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"); + throw new Error("useMenuContext must be used within a MenuProvider"); } return context; }; diff --git a/src/components/Menu/MenuDrawer.tsx b/src/components/Menu/MenuDrawer.tsx index 97b92c2..31da2d7 100644 --- a/src/components/Menu/MenuDrawer.tsx +++ b/src/components/Menu/MenuDrawer.tsx @@ -1,5 +1,6 @@ import React from "react"; import { twJoin } from "tailwind-merge"; +import { Portal } from "../Portal"; interface MenuDrawerProps { isOpen: boolean; @@ -36,73 +37,102 @@ export const MenuDrawer: React.FC = ({ right: "text-right", }; - return ( - <> - {fullHeight && isOpen && onBackdropClick && ( -
- )} - + const renderHeader = () => { + if (!(showBackButton || title)) return null; + return (
- {/* Header */} - {(showBackButton || title) && ( -
- {showBackButton && ( - + + )} - {title && ( -

- {title} -

+ + )} + {title && ( +

+ > + {title} +

)} - -
{children}
+ ); + }; + + const renderContent = () => ( + <> + {renderHeader()} +
{children}
); + + // For fullHeight drawers (mobile), use Portal to ensure proper theme sync + if (fullHeight) { + return ( + + {onBackdropClick && ( +
+ )} + +
+ {renderContent()} +
+ + ); + } + + // For non-fullHeight drawers (desktop), render normally + return ( +
+ {renderContent()} +
+ ); }; diff --git a/src/index.tsx b/src/index.tsx index 88fbfb9..b50ca9e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -33,6 +33,7 @@ export * from "./widgets/sections/AmountSubsection"; export * from "./widgets/sections/FinalityProviderSubsection"; export * from "./widgets/sections/FeesSection"; export * from "./widgets/sections/PreviewModal"; +export * from "./widgets/new-design/SettingMenu"; export * from "./elements/FinalityProviderLogo"; export * from "./elements/FinalityProviderItem"; diff --git a/src/widgets/new-design/SettingMenu/SettingMenu.stories.tsx b/src/widgets/new-design/SettingMenu/SettingMenu.stories.tsx new file mode 100644 index 0000000..3ad54f9 --- /dev/null +++ b/src/widgets/new-design/SettingMenu/SettingMenu.stories.tsx @@ -0,0 +1,103 @@ +import { Meta, StoryFn } from "@storybook/react"; +import { useState } from "react"; +import { SettingMenu } from "./SettingMenu"; +import { Button } from "@/components/Button"; + +export default { + title: "Widgets/New Design/SettingMenu", + component: SettingMenu, + argTypes: { + onOpenChange: { action: "open state changed" }, + }, + tags: ["autodocs"], +} as Meta; + +const ThemeIcon = () => ( + + + + +); + +const ReportABugIcon = () => ( + + + + +); + +export const Default: StoryFn = () => { + const [selectedTheme, setSelectedTheme] = useState<"light" | "dark" | "auto">("auto"); + + const handleThemeChange = (theme: "light" | "dark" | "auto") => { + setSelectedTheme(theme); + console.log(`${theme} theme selected`); + }; + + const getThemeDescription = () => { + switch (selectedTheme) { + case "light": + return "Light"; + case "dark": + return "Dark"; + case "auto": + return "Auto"; + default: + return "Auto"; + } + }; + + return ( +
+ + Settings + + + }> + Theme + {getThemeDescription()} + handleThemeChange("light")}> + Light + + handleThemeChange("dark")}> + Dark + + handleThemeChange("auto")}> + Auto + + + + } onClick={() => console.log("Report bug clicked")}> + Report a Bug + + + + + + + window.open("https://example.com/terms", "_blank")}> + Terms of Use + + + window.open("https://example.com/privacy", "_blank")}> + Privacy Policy + + + + + + + + + +
+ ); +}; diff --git a/src/widgets/new-design/SettingMenu/SettingMenu.tsx b/src/widgets/new-design/SettingMenu/SettingMenu.tsx new file mode 100644 index 0000000..7196210 --- /dev/null +++ b/src/widgets/new-design/SettingMenu/SettingMenu.tsx @@ -0,0 +1,185 @@ +import React from "react"; +import { Menu } from "@/components/Menu"; +import { MenuItem } from "@/components/Menu/MenuItem"; +import { SubMenu } from "@/components/Menu/SubMenu"; +import { Text } from "@/components/Text"; +import { + type SettingMenuProps, + type SettingMenuTitleProps, + type SettingMenuGroupProps, + type SettingMenuItemProps, + type SettingMenuSubMenuProps, + type SettingMenuDescriptionProps, + type SettingMenuCustomContentProps, +} from "./types"; +import { twJoin } from "tailwind-merge"; + +const SettingsIcon = () => ( + + + +); + +const ChevronRightIcon = () => ( + + + +); + +interface SettingMenuComponent extends React.FC { + Title: React.FC; + Group: React.FC; + Item: React.FC; + SubMenu: React.FC; + Description: React.FC; + CustomContent: React.FC; + Spacer: React.FC<{ size?: "sm" | "md" | "lg" }>; +} + +const SettingMenuBase: React.FC = ({ + trigger, + open, + onOpenChange, + placement = "bottom-end", + className, + offset = [0, 8], + children, +}) => { + const defaultTrigger = ( + + ); + + return ( + + {children} + + ); +}; + +const SettingMenuTitle: React.FC = ({ children, className = "" }) => ( + + {children} + +); + +const SettingMenuGroup: React.FC = ({ + background = "none", + backgroundClassName = "", + className = "", + children, +}) => { + const getBackgroundClass = () => { + switch (background) { + case "secondary": + return "mx-[21px] rounded-lg bg-secondary-highlight md:mx-0 md:bg-transparent"; + case "custom": + return backgroundClassName; + default: + return ""; + } + }; + + return
{children}
; +}; + +// Item component +const SettingMenuItem: React.FC = ({ + icon, + suffix, + onClick, + disabled = false, + selected = false, + className = "", + children, +}) => { + const childrenArray = React.Children.toArray(children); + const label = childrenArray.find( + (child) => typeof child === "string" || (React.isValidElement(child) && child.type !== SettingMenuDescription), + ); + const description = childrenArray.find( + (child) => React.isValidElement(child) && child.type === SettingMenuDescription, + ); + + const labelText = typeof label === "string" ? label : ""; + + return ( + : suffix} + /> + ); +}; + +const SettingMenuSubMenu: React.FC = ({ icon, className = "", children }) => { + const childrenArray = React.Children.toArray(children); + const label = childrenArray.find( + (child) => typeof child === "string" || (React.isValidElement(child) && child.type !== SettingMenuDescription), + ); + const description = childrenArray.find( + (child) => React.isValidElement(child) && child.type === SettingMenuDescription, + ); + const subMenuItems = childrenArray.filter((child) => React.isValidElement(child) && child.type === SettingMenuItem); + + const labelText = typeof label === "string" ? label : ""; + + return ( + } + > + {subMenuItems} + + ); +}; + +const SettingMenuDescription: React.FC = ({ children }) => { + return <>{children}; +}; + +const SettingMenuCustomContent: React.FC = ({ children, className = "" }) => ( +
{children}
+); + +const SettingMenuSpacer: React.FC<{ size?: "sm" | "md" | "lg" }> = ({ size = "md" }) => { + const sizeClasses = { + sm: "mt-2", + md: "mt-4", + lg: "mt-6", + }; + + return
; +}; + +export const SettingMenu = SettingMenuBase as SettingMenuComponent; + +SettingMenu.Title = SettingMenuTitle; +SettingMenu.Group = SettingMenuGroup; +SettingMenu.Item = SettingMenuItem; +SettingMenu.SubMenu = SettingMenuSubMenu; +SettingMenu.Description = SettingMenuDescription; +SettingMenu.CustomContent = SettingMenuCustomContent; +SettingMenu.Spacer = SettingMenuSpacer; diff --git a/src/widgets/new-design/SettingMenu/index.ts b/src/widgets/new-design/SettingMenu/index.ts new file mode 100644 index 0000000..9a6a56b --- /dev/null +++ b/src/widgets/new-design/SettingMenu/index.ts @@ -0,0 +1,10 @@ +export { SettingMenu } from "./SettingMenu"; +export type { + SettingMenuProps, + SettingMenuTitleProps, + SettingMenuGroupProps, + SettingMenuItemProps, + SettingMenuSubMenuProps, + SettingMenuDescriptionProps, + SettingMenuCustomContentProps, +} from "./types"; diff --git a/src/widgets/new-design/SettingMenu/types.ts b/src/widgets/new-design/SettingMenu/types.ts new file mode 100644 index 0000000..c12643c --- /dev/null +++ b/src/widgets/new-design/SettingMenu/types.ts @@ -0,0 +1,72 @@ +import { ReactNode } from "react"; + +export interface SettingMenuProps { + /** Custom trigger element (defaults to settings icon button) */ + trigger?: ReactNode; + /** Controlled open state */ + open?: boolean; + /** Callback when open state changes */ + onOpenChange?: (open: boolean) => void; + /** Placement of the menu relative to trigger */ + placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end"; + /** Additional CSS classes for the menu container */ + className?: string; + /** Offset from the trigger element */ + offset?: [number, number]; + /** Children components */ + children: ReactNode; +} + +export interface SettingMenuTitleProps { + children: ReactNode; + className?: string; +} + +export interface SettingMenuGroupProps { + /** Background style for the group */ + background?: "none" | "secondary" | "custom"; + /** Custom background className when background="custom" */ + backgroundClassName?: string; + /** Additional CSS classes */ + className?: string; + /** Children components */ + children: ReactNode; +} + +export interface SettingMenuItemProps { + /** Icon element */ + icon?: ReactNode; + /** Suffix element (e.g., chevron, external link icon) */ + suffix?: ReactNode; + /** Click handler */ + onClick?: () => void; + /** Disabled state */ + disabled?: boolean; + /** Selected/active state */ + selected?: boolean; + /** Additional CSS classes */ + className?: string; + /** Children (label and description) */ + children: ReactNode; +} + +export interface SettingMenuDescriptionProps { + children: ReactNode; + className?: string; +} + +export interface SettingMenuSubMenuProps { + /** Icon element */ + icon?: ReactNode; + /** Click handler for the submenu trigger */ + onClick?: () => void; + /** Additional CSS classes */ + className?: string; + /** Children (label, description, and submenu items) */ + children: ReactNode; +} + +export interface SettingMenuCustomContentProps { + children: ReactNode; + className?: string; +}